Skip to content

Enhance spoken alarm feedback and controls - #334

Merged
nedtwigg merged 7 commits into
mainfrom
speak-alarms
Aug 17, 2026
Merged

Enhance spoken alarm feedback and controls#334
nedtwigg merged 7 commits into
mainfrom
speak-alarms

Conversation

@nedtwigg

Copy link
Copy Markdown
Member

Summary

  • remove asterisks from spoken terminal labels so they are not announced aloud
  • track actual speech-engine delivery and retain a per-Session SPEAKING / SPOKEN state until the alarm is deliberately attended
  • add loud, theme-adaptive Pane and Door treatments for active and completed speech
  • expand the baseboard alarm controls into separate audio-status, push-status, and general settings buttons
  • update the alert, layout, theme, and design documentation together with focused tests and Storybook scenarios

Why

Spoken alarms were difficult to associate with a terminal, and labels containing * produced confusing announcements such as “eight asterisk.” The settings affordance also hid whether speech and push delivery were enabled. This makes delivery state unmistakable while keeping resolution tied to deliberate attention rather than mere visibility.

Validation

  • pnpm test
  • pnpm --filter dormouse-lib build
  • pnpm --filter dormouse-lib build-storybook
  • node scripts/spec-lint.mjs
  • Storybook visual QA in light and Monokai themes
  • git diff --check

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 29, 2026

Copy link
Copy Markdown

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: 2d35049
Status: ✅  Deploy successful!
Preview URL: https://69c69c07.mouseterm.pages.dev
Branch Preview URL: https://speak-alarms.mouseterm.pages.dev

View logs

nedtwigg and others added 5 commits August 17, 2026 10:54
Review findings on the speak-alarms branch, all of them in the two feature
commits rather than the merge.

Speech lifecycle (`alert-speech.ts`):

- `onEnd`/`onError` were guarded by `if (utterance)`, but `utterance` was only
  assigned after `speak()` returned. An engine that dispatches `start` then
  `end`/`error` synchronously inside `synth.speak()` — Chrome reports
  `not-allowed` that way when speech runs without a user gesture, which is this
  exact call site — had its settle silently dropped, pinning the Session at
  SPEAKING for the rest of the ring. Handlers now close over the utterance
  itself and registration happens before dispatch; a refused dispatch settles
  too.
- The disposer detached handlers but never called `speechSynthesis.cancel()`,
  so a webview unmounting mid-alarm kept reading Pane names aloud with no
  visible source and no UI left to stop it.
- The tracking Set was only drained by `settle()`, which a WebKit-dropped
  utterance never reaches — so a wedged synthesizer accumulated handler
  closures for the life of the app. Bounded by evicting the oldest.
- `clearResolvedSpeech` ran on every activity notification and rebuilt the
  activity Map that notification had just invalidated; it now bails when no
  delivery state exists. Its seed call was provably a no-op and is gone.

Chrome:

- Door: `spoken` persists until the ring is attended, which is unbounded, so it
  no longer evicts the bell and TODO pill — it adds a speaker icon beside them.
  `speaking` keeps the full takeover; it lasts one utterance.
- AlertSpeechIndicator: `.lath-leaf-header` is a stacking context at z-20, which
  traps its own `z-[1000]` popovers there, so the single z-25 overlay covered
  and tinted every header menu. Split into a wash+label layer at z-19 (below the
  header, above terminal content) and a perimeter ring at z-25 (still outlines
  the whole Pane, covers nothing). Geometry now derives from the design
  constants instead of a hardcoded 34px and `rounded-lg`.
- Baseboard: measured door widths fed a ref while the fitting budget is computed
  during render, so the frame that first showed a wide SPEAKING/SPOKEN door
  fitted against the old widths with no re-render to correct it. Now state.
- The pulse honors `cfg.alert.ringingPaused` like the bell, so the new Speaking
  stories stop producing non-deterministic Chromatic snapshots. Its dead
  `motion-reduce:animate-none` (unreachable beside `motion-safe:`) is gone.
- LathHost gained a generic per-leaf overlay slot resolved through the same
  registry as Body/Tab, replacing the hardcoded `component === 'terminal'`
  branch in the content-agnostic engine adapter.

Specs updated to match, including an alert.md bullet that said asterisks both
"become spaces" and "are removed".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…h chrome

Cleanup pass over the previous commit. One correction and several trims.

The correction: the previous commit split the pane overlay into two layers on
the grounds that `.lath-leaf-header`'s stacking context traps its own popovers,
so a single z-25 overlay would cover them. That premise is wrong — every header
popover (pane context menu, title candidates, notification preview, rename
warning) portals to `document.body` with `position: fixed`, so none of them was
ever inside the leaf. The split is still worth keeping for the reasons it
actually delivers — the wash no longer tints the header band, where
`--color-alarm-vs-terminal` is a contrast pick computed against the terminal
body, nor the z-20 pane-corner banners — so the layers stay and the rationale is
rewritten in the component, the spec, and the test name.

Following the Door's own rule to its conclusion: SPOKEN no longer washes the
terminal body either. It persists until the ring is attended, which is
unbounded, and degrading terminal-text contrast for that whole window is the
same mistake the Door's badge cluster was just fixed to avoid. The ring carries
it; SPEAKING keeps the wash, lasting one utterance.

Also:

- `alertSpeakingAnimationClass` moves to `bell-icon-class.ts`, beside the other
  Chromatic-frozen alert animation, so `cfg` stops leaking into design.tsx.
- Class strings in Door and AlertSpeechIndicator use `clsx` (already a direct
  dependency, and design.tsx's own idiom) instead of `[...].join(' ')` with
  empty-string placeholders, which emitted stray double spaces.
- `track()`'s while-loop becomes an `if`: it is the only insertion point and
  adds one entry per call, so the size can never exceed the cap.
- Comments trimmed where they overclaimed: the LathHost overlay registry does
  not decouple anything (LathHost still imports the indicator), the Baseboard
  equality guard prevents an extra render rather than a loop, and the
  `clearResolvedSpeech` early-out saves nothing measurable because
  `getActivitySnapshot` memoizes and Baseboard rebuilds it in the same
  notification.
- Tests: the utterance-bounding test asserted a fact that held regardless of the
  cap; it now pins the tracked tail exactly. Dropped two assertions that could
  not fail and one duplicate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ring

origin/main replaced the restored-pane resume offer with the new recovery flow
and deleted ResumeBanner, so the spoken-alarm overlay's z-order note named a
banner that no longer exists. MouseOverrideBanner is now the only z-20
pane-corner banner the wash has to stay under.

Textual merge was clean; this is the semantic half.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@nedtwigg
nedtwigg marked this pull request as ready for review August 17, 2026 22:45

@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.

Reviewing as a draft — flagging anything that looks worth a quick fix. Mark ready for a full review.

Read through the speech lifecycle, the store, the overlay layering, and the spec updates; the synchronous-settle handling, the generation token, and the speechSynthesis.cancel() on teardown all hold up, and the test coverage tracks them closely. One thing stood out.

The two new baseboard status buttons use the standard mute-toggle icon pair (SpeakerHigh/SpeakerSlash, BellRinging/BellSlash), so a slashed speaker labelled "Spoken alarms disabled" reads as "click to enable". The aria-labels do say ; open Alarm settings, but title is the only affordance hint a sighted mouse user gets and it omits the action entirely — so exactly the users the icon misleads are the ones not told what the click does. Suggestions below bring title in line with aria-label; if the icons are meant to look like toggles, the alternative is making them actually toggle speakEnabled/pushEnabled and leaving the sliders button as the way into the dialog.

<button
className={`${BASEBOARD_BUTTON_CLASS} ${settings.speakEnabled ? 'text-app-fg' : ''}`}
aria-label={`Spoken alarms ${settings.speakEnabled ? 'enabled' : 'disabled'}; open Alarm settings`}
title={`Spoken alarms ${settings.speakEnabled ? 'enabled' : 'disabled'}`}

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.

Suggested change
title={`Spoken alarms ${settings.speakEnabled ? 'enabled' : 'disabled'}`}
title={`Spoken alarms ${settings.speakEnabled ? 'enabled' : 'disabled'} — open Alarm settings`}

<button
className={`${BASEBOARD_BUTTON_CLASS} ${settings.pushEnabled ? 'text-app-fg' : ''}`}
aria-label={`Push notifications ${settings.pushEnabled ? 'enabled' : 'disabled'}; open Alarm settings`}
title={`Push notifications ${settings.pushEnabled ? 'enabled' : 'disabled'}`}

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.

Suggested change
title={`Push notifications ${settings.pushEnabled ? 'enabled' : 'disabled'}`}
title={`Push notifications ${settings.pushEnabled ? 'enabled' : 'disabled'} — open Alarm settings`}

@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.

Full review now that this is out of draft. The speech lifecycle holds up under a close read — the synchronous-settle path, the generation token, the speechSynthesis.cancel() on teardown, and the doorWidthsRef → state change (the fitting budget runs during render, so a ref write really would have stranded a stale frame) are all right, and the tests pin the tricky parts rather than the easy ones. One new thing, plus the two baseboard title threads from the draft pass, which are still open.

The SPOKEN invariant this PR states in three places — docs/specs/layout.md ("A Door that hid both persistent signals for that whole window would be indistinguishable from a quiet one"), DESIGN.md, and the comment on Door.test.tsx's third case — holds visually but not in the accessible name. aria-label replaces the whole computed name rather than adding to it, so a Door rendering title + the TODO pill announces as build-server, spoken: the TODO is dropped from the accessible name for exactly the unbounded window the rest of the design goes out of its way to protect. SPEAKING is fine — the badge cluster genuinely is replaced there, and it lasts one utterance — so the fix only needs to cover the persistent state. Suggestion inline. (The bell contributes nothing to the name either way; the Phosphor <svg> carries no accessible text, so TODO is the only signal actually lost.)

onPointerDown={onPointerDown}
title={title}
title={speechState ? `${title} — ${speechState}` : title}
aria-label={speechState ? `${title}, ${speechState}` : undefined}

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.

Suggested change
aria-label={speechState ? `${title}, ${speechState}` : undefined}
aria-label={speechState ? `${title}${todo ? ', TODO' : ''}, ${speechState}` : undefined}

aria-label overrides the computed name outright, so while spoken is set the TODO pill stops being announced — for the whole unbounded window layout.md and the test comment below argue it must not go dark. Using todo rather than todoPill.visible keeps the flourish (a TODO that just cleared) out of the name.

@nedtwigg
nedtwigg merged commit 39feda6 into main Aug 17, 2026
15 checks passed
@nedtwigg
nedtwigg deleted the speak-alarms branch August 17, 2026 23:02
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