Skip to content

Every ringing Pane wears the alarm treatment; speech only adds the label - #709

Draft
nedtwigg wants to merge 2 commits into
alert-dismiss-simplifyfrom
alert-ring-flash
Draft

nedtwigg wants to merge 2 commits into
alert-dismiss-simplifyfrom
alert-ring-flash

Conversation

@nedtwigg

Copy link
Copy Markdown
Member

Stacked on #707.

Until now a plain ALERT_RINGING Session showed only the bell glyph; the whole-Pane wash + perimeter ring existed only for spoken-alarm delivery. Now every latched ring wears it, and the speech sink only picks the label:

row wash ring label motion
ringing 10% 3px none one finite burst per episode
speaking 20% 5px SPEAKING pulse for the utterance
spoken 10% 3px SPOKEN static
  • AlertSpeechIndicatorAlertRingIndicator, gated on the Activity store with per-Session selectors; the burst keys on the alert episode and is clocked from its startedAt, so a remount mid-episode never replays it.
  • One row derivation (alertRingRow) and one motion home (lib/src/components/alert-ring.ts) shared by the Pane overlay and the Door; the Door wears a single inset overlay for ringing and spoken.
  • Only the perimeter ring animates (halves the composited layers); keyframes renamed alarm-pulse.
  • layout.md → "Spoken-alarm overlay" is now "Alarm overlay" and the single inventory of the rows; alert.md, DESIGN.md, theme.md point at it. No budget change.

The bell stays in this PR so the diff reviews cleanly; the next PR retires it.

🤖 Generated with Claude Code

nedtwigg and others added 2 commits September 20, 2026 00:14
A latched ring used to show only a swinging bell and a tinted header; the
whole-Pane wash, perimeter ring, and label were reserved for spoken-alarm
delivery. Now the treatment is the ring indicator itself: unlabelled at
10% wash / 3px ring while nothing has spoken, and SPEAKING / SPOKEN
stacking on top when the speech sink acts.

The unlabelled state flashes a bounded four-cycle burst on arrival and
then holds, keyed on the Activity episode and clocked from its start
time, so a second track latching inside one episode does not re-flash and
a minimize/reattach does not replay a burst that already expired.
prefers-reduced-motion and cfg.alert.ringingPaused suppress the burst and
keep the static treatment, exactly as they do for the speaking pulse.

A ringing Door gets the same static inset ring spoken already uses, with
the same arrival burst and "needs attention" in its accessible name.

AlertSpeechIndicator is now AlertRingIndicator and reads the Activity
store as its gate; the shared alarm animation helpers move from
bell-icon-class.ts to design.tsx. The bell stays for now.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The Pane overlay and the Door each paired `status` with `speechState` on
their own and each reached for its own freeze-aware animation class. Both
now read `alertRingRow(status, speechState)` and `alertRingBurstProps(row,
episode)` from lib/src/components/alert-ring.ts, which also owns the
accessible-name table, the single `alarmPulseClass(bounded)` body, and the
`animationClockStyle(startedAt)` the bell already computed inline.

The bundle exists so the class, the remount key, and the CSS clock can
never be applied apart: a Door that keyed on an absent episode never
replayed its burst, and the frozen Chromatic build still wrote a dead
`animationDelay` into every snapshot. `useAlertRingBurst` anchors that
clock once per episode, so a re-render no longer shoves a live burst back
to its start.

The overlay now animates its perimeter ring alone rather than the wash
too, so an alarming Pane composites one layer instead of two, and both
components subscribe per Session, so a ring on one Pane no longer
re-renders the overlay on every other.

The Door paints the unlabelled and SPOKEN edges from one inset overlay
(`DOOR_ALARM_INSET_CLASS`) rather than a root shadow plus a child, and
drops `data-alert-speech-state` for `data-alert-ring-state`.

CSS: `speech-alarm-pulse` is now `alarm-pulse`, behind
`--animate-alarm-pulse` and `--animate-alarm-pulse-burst`.

Both spec edits fit without a budget change, so the alert.md ratchet from
the previous commit is refunded.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: 7bad90e
Status: ✅  Deploy successful!
Preview URL: https://31fb148c.mouseterm.pages.dev
Branch Preview URL: https://alert-ring-flash.mouseterm.pages.dev

View logs

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feedback on work in progress — not a merge verdict. Mark the PR ready when you want the full review.

The rewrite reads cleanly: alertRingRow as the single gate is a real improvement over each surface pairing status with speechState, and gating on the latched ring closes the window where a spoken state outlived its episode (afterScan in lib/src/lib/alert-speech.ts only clears it on the next scan, so the old AlertSpeechIndicator kept painting until then). Anchoring the burst on episode.startedAt rather than mount is right, and the Door comment about never keying the cached element earns its place.

One thing the docs edit drops. layout.md deleted Under SPEAKING both pulse when motion is allowed and cfg.alert.ringingPaused is not set and now hands motion off — Only the perimeter ring animates ... docs/specs/alert.md → Pane Header owns the motion — but the Pane Header paragraph it points at states only the unlabelled burst bound. So after this PR no spec says that SPEAKING pulses for its utterance and SPOKEN never animates. SPOKEN is the unbounded row, and an unbounded full-Pane animation is exactly the cost alert.rationale.md argues against, so by AGENTS.md's deletion test (if deleting a sentence would let a careful editor reintroduce a bug ... its one-line form stays in the spec) that rule belongs in the body. Your own PR description carries the Motion column the specs no longer have.

docs/specs/alert.md is at 7193 words against a 7200 budget, so there are 7 words of headroom — the inline suggestion is exactly +7 and holds your "no budget change". If you would rather also name the pinning test, which that section does for every neighbouring rule (Name the test that pins a rule), that needs a --ratchet in the same PR.

The other note is test hygiene, inline.

Checked and fine
  • Rename is complete: no surviving reference to speech-alarm-pulse, alertSpeakingAnimationClass, data-alert-speech-state, data-alert-speech-wash, AlertSpeechIndicator, or Spoken-alarm anywhere outside .git.
  • bell-icon-class.ts still uses its cfg import after alertSpeakingAnimationClass was removed, so no unused-import lint.
  • The inline animationDelay beats the animation shorthand the Tailwind animate-* class emits, so the burst clock survives — same mechanism AlertBell already relies on.
  • if (!row || !burst) return null is reachable only with a ringing status and no episode; setTerminalActivity is the sole writer of terminalActivity and hydrates one on every ringing transition, so the pair does arrive together.
  • useSyncExternalStore per-Session selectors do bail out: getActivity returns the stored object identity and getAlertSpeechState a primitive, so a ring on one Pane re-renders one overlay.
  • useAlertRingBurst not depending on cfg.alert.ringingPaused is safe — it is only ever set in lib/.storybook/preview.ts, never toggled at runtime.
  • Baseboard's hidden measurement pass draws the ring identically, and absolute inset-0 adds no width, so the fitting pass still measures what the visible Door draws.

Note that Visual Regression Tests skips while the PR is a draft, so the Chromatic baselines for a change that is entirely visual land only when you mark it ready.

Comment thread docs/specs/alert.md
The TODO pill always displays `TODO`; remote notification text belongs in preview/detail surfaces, not inside the pill. Clicking the pill clears TODO, and on clear the pill briefly shows the success flourish before unmounting.

Spoken-alarm delivery is much louder than the bell: a pointer-transparent treatment spans the whole terminal Pane, labelled `SPEAKING` while the engine actually speaks and `SPOKEN` — quieter, and unbounded — until the ring resolves. **`prefers-reduced-motion` keeps the strong static treatment and suppresses only the pulse**, as does `cfg.alert.ringingPaused` (rationale). The layers, their strengths, placement, and sizing belong to `docs/specs/layout.md` → Spoken-alarm overlay.
**Must wear the alarm treatment on every ringing terminal Pane**, labelled only once the speech sink acts. **Must bound the unlabelled pulse to one finite burst per episode, never replayed by a remount** (rationale). **`prefers-reduced-motion` keeps the strong static treatment and suppresses only the pulse**, as does `cfg.alert.ringingPaused` (rationale). The three rows, their layers, strengths, and sizing are inventoried by `docs/specs/layout.md` → Alarm overlay.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Pane's SPEAKING/SPOKEN motion rule has no home after this edit: layout.md dropped "Under SPEAKING both pulse when motion is allowed" and now defers motion here, but this paragraph covers only the unlabelled burst. Nothing in either spec stops an editor animating SPOKEN — the unbounded row, and the cost the rationale exists to argue against. Exactly +7 words, which is the headroom alert.md has left against its 7200-word budget.

Suggested change
**Must wear the alarm treatment on every ringing terminal Pane**, labelled only once the speech sink acts. **Must bound the unlabelled pulse to one finite burst per episode, never replayed by a remount** (rationale). **`prefers-reduced-motion` keeps the strong static treatment and suppresses only the pulse**, as does `cfg.alert.ringingPaused` (rationale). The three rows, their layers, strengths, and sizing are inventoried by `docs/specs/layout.md` → Alarm overlay.
**Must wear the alarm treatment on every ringing terminal Pane**, labelled only once the speech sink acts. **Must bound the unlabelled pulse to one finite burst per episode, never replayed by a remount; `SPEAKING` pulses for its utterance, `SPOKEN` never** (rationale). **`prefers-reduced-motion` keeps the strong static treatment and suppresses only the pulse**, as does `cfg.alert.ringingPaused` (rationale). The three rows, their layers, strengths, and sizing are inventoried by `docs/specs/layout.md` → Alarm overlay.

Comment on lines +123 to +124
expect(leaf?.querySelector('[data-session-id]')?.getAttribute('data-session-id')).toBe('pane-a');
});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pane-a stays ALERT_RINGING for the rest of the file: this suite's afterEach resets the archive service and notepads but not the activity store, and harness.dispose() only unmounts the root. Every later test that renders a pane-a inherits the alarm overlay and a ringing bell in its header — nothing asserts on either today, so this is latent rather than broken, but it is the kind of leak the next test added here pays for. The saveState test further down clears it inline, same shape.

Suggested change
expect(leaf?.querySelector('[data-session-id]')?.getAttribute('data-session-id')).toBe('pane-a');
});
expect(leaf?.querySelector('[data-session-id]')?.getAttribute('data-session-id')).toBe('pane-a');
// The activity store is Window-global and this file's `afterEach` does not reset it.
await act(async () => { clearTerminalActivity(); });
});

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants