diff --git a/DESIGN.md b/DESIGN.md index c8fbcbb2..fb87c5ec 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -22,6 +22,7 @@ colors: error: "var(--vscode-terminal-ansiRed)" success: "var(--vscode-terminal-ansiGreen)" alarm: "var(--vscode-terminal-ansiYellow)" + alarm-vs-terminal: "var(--color-alarm-vs-terminal)" window-close-hover: "#b92a1b" typography: body: @@ -136,7 +137,7 @@ This system has no "primary" accent in the brand sense. The closest analogue is - **Terminal Background / Foreground** (`var(--vscode-terminal-background)` / `var(--vscode-terminal-foreground)`): the terminal content surface and xterm default text. Orthogonal to the chrome. - **Error** (`var(--vscode-terminal-ansiRed)`): destructive actions and kill-confirm letter flash. - **Success** (`var(--vscode-terminal-ansiGreen)`): TODO check, theme-store install confirm. -- **Alarm** (`var(--vscode-terminal-ansiYellow)` baseline; runtime-overridden): bell-ringing alert tint. `computeDynamicPalette()` replaces each `--color-alarm-vs-*` token with plain white or black by the OKLab lightness of the bg the bell sits on (active header, inactive header, or door), so the ringing bell stays maximally legible on any surface. +- **Alarm** (`var(--vscode-terminal-ansiYellow)` baseline; runtime-overridden): alert tint. `computeDynamicPalette()` replaces each `--color-alarm-vs-*` token with plain white or black by the OKLab lightness of its background (active header, inactive header, Door, or terminal body), so ringing bells and the whole-Pane spoken-alarm treatment stay maximally legible on any surface. ### Fixed Exception - **Window Close Hover** (`#b92a1b`): the only literal color in the whole system. Native OS close-button hover on Windows/Linux chrome buttons; matches the platform convention across themes. @@ -196,6 +197,7 @@ Doors are the pane-header indicators on the baseboard. The most signature compon - **Dimensions:** `h-6` (24px), `min-w-[68px]`, `max-w-[220px]`, horizontal padding `px-2.5` (10px), `gap-2` between title and badges. - **Type:** `text-sm font-medium font-mono`. - **Content:** truncated title; optional TODO pill (`text-xs font-semibold tracking-[0.08em]`, success-tinted when flourishing); optional bell icon (`size={11}`, `weight="fill"`), `text-alarm-vs-door` when ringing. +- **Spoken alarm:** transient delivery outranks the compact TODO/bell cluster. `SPEAKING` inverts and pulses the whole Door; `SPOKEN` retains a static 2px inset. Both include a speaker icon plus explicit text, so neither depends on color or motion. - **Hover/Focus:** no decorative hover. The whole door is a button; the focus state is conveyed by the parent pane's selection ring, not by a per-door treatment. ### Buttons diff --git a/docs/specs/alert.md b/docs/specs/alert.md index de8cca6f..3e3c84f2 100644 --- a/docs/specs/alert.md +++ b/docs/specs/alert.md @@ -153,10 +153,12 @@ When a Session transitions into `ALERT_RINGING` and is still ringing `speakDelay - Any of the three tracks qualifies. "Not attended" is track-agnostic. - **The derived Pane label is spoken, including terminal-supplied title overrides.** It comes from `deriveSessionLabel` in `lib/src/lib/session-label.ts` — the one id-keyed label derivation shared with the dev-server chip — and falls back to `terminal`. `OSC 0`, `OSC 2`, and legacy `OSC 9` message text can therefore be spoken when that text currently wins the normal Pane-label derivation. This is deliberate: opting into spoken alarms opts into hearing the Pane name Dormouse displays, even when a program supplied that name. A ringing `ActivityNotification` title/body is not itself the speech payload, but an `OSC 9` message body is also an input to normal Pane-label derivation and can be spoken on that basis. -- **The label is sanitized before it reaches the engine** (`toSpokenText` in `lib/src/lib/alert-speech.ts`): angle brackets, ampersands, and control characters become spaces, whitespace collapses, the result is capped, and an empty result falls back to `terminal`. This is a robustness *and* security requirement, not tidiness — WebKit silently drops an utterance containing angle brackets **and leaves the synthesizer wedged**, so every later utterance is dropped too until the page reloads. Pane labels carry chrome like ``, and terminal-supplied titles reach speech, so without sanitization any program could permanently disable spoken alarms for the session by putting a `<` in its title. +- **The label is sanitized before it reaches the engine** (`toSpokenText` in `lib/src/lib/alert-speech.ts`): angle brackets, ampersands, asterisks, and control characters become spaces, whitespace collapses, the result is capped, and an empty result falls back to `terminal`. This is a robustness *and* security requirement, not tidiness — WebKit silently drops an utterance containing angle brackets **and leaves the synthesizer wedged**, so every later utterance is dropped too until the page reloads. Pane labels carry chrome like ``, and terminal-supplied titles reach speech, so without sanitization any program could permanently disable spoken alarms for the session by putting a `<` in its title. Asterisks are removed for clarity: a label such as `eight *` must not be announced as “eight asterisk.” - The trigger is a fresh transition into `ALERT_RINGING`, held to the same standard as the bell (WATCHING Track, last bullet). A Session observed for the first time *already* ringing never speaks, which is what keeps a restore or a reconnect replaying a latched ring silent. - Attending, dismissing, or killing the Pane during the delay cancels the utterance; so does switching the setting off. Both the ring and the setting are re-read when the timer fires rather than captured when it was scheduled. - One utterance per ring. A Session that rings, is cleared, and rings again speaks twice. Sessions ring and speak independently. +- **Delivery state follows actual engine callbacks, not queue admission.** `AlertSpeechState` in `lib/src/lib/alert-speech-state.ts` is a renderer-local `speaking | spoken` map keyed by Session. The engine's `start` event publishes `speaking`; `end`, or `error` after a real start, publishes `spoken`. An utterance that never starts publishes neither. Each utterance carries an opaque generation token, so a late callback from a resolved or older ring cannot overwrite a newer ring or resurrect a cleared marker. +- `speaking` / `spoken` remains only while the originating Session is still `ALERT_RINGING`. Any deliberate action that resolves the ring clears it: clicking or entering the Pane, typing in passthrough, clicking/pressing `Enter` on its Door, dismissing the bell, or marking/clearing TODO. Mere visibility, hover, or command-mode selection does not. Killing the Session also clears it. The state is not persisted or sent to the host, so restore/reconnect never recreates it. - Renderer-side, via `window.speechSynthesis`. Where that is absent — Tauri on Linux (WebKitGTK ships no speech backend), or a test environment — speaking is a silent no-op rather than an error. `speak()` is the single seam a native host path would replace. - Desktop shell only: `MobileWall` / Pocket does not arm it and has no settings UI. @@ -220,6 +222,8 @@ The dialog carries the TODO switch, the WATCHING rule switch for the running com The TODO pill always displays `TODO`; remote notification text belongs in preview/detail surfaces, not inside the pill. Clicking the pill clears TODO. On clear, the pill briefly shows the success flourish before unmounting. +Spoken-alarm delivery is deliberately much louder than the bell. While the engine is actually speaking, a pointer-transparent treatment spans the whole terminal Pane with an animated high-contrast inset and an explicit `SPEAKING` label. After the utterance settles, the animation stops but a static high-contrast inset and `SPOKEN` label remain until the ring is resolved. `prefers-reduced-motion` keeps the strong static `SPEAKING` treatment and suppresses only the pulse. Placement and sizing belong to `docs/specs/layout.md`; source of truth: `lib/src/components/wall/AlertSpeechIndicator.tsx`. + ### Door A Door is display-only for alert state: @@ -227,6 +231,7 @@ A Door is display-only for alert state: - show the bell only when `status !== 'WATCHING_DISABLED'` - show the TODO pill when `todo === true` - use the same bell tilt/animation mapping as the Pane header +- while its Session is `speaking` or `spoken`, replace the compact bell/TODO cluster with the explicit speech label; `SPEAKING` inverts and pulses the whole Door, while `SPOKEN` keeps a static high-contrast inset - do not expose a Door-specific alert menu Click or `Enter` on a Door reattaches into passthrough, counts as attention, and clears a ring. `d` reattaches in command mode, does not count as attention, and leaves the ring intact. @@ -255,6 +260,7 @@ Alert-specific robustness requirements: multiple Sessions ring independently; mi | `lib/src/lib/alert-settings-host.ts` | First-seed + replace/broadcast coordinator for the settings blob | | `lib/src/lib/alert-ring-watch.ts` | The shared unattended-ring machine: fresh-ring detection, the delay, the re-check, cancellation | | `lib/src/lib/alert-speech.ts` | The speech sink and `toSpokenText` | +| `lib/src/lib/alert-speech-state.ts` | Transient per-Session `speaking` / `spoken` delivery state | | `lib/src/remote/host/alert-push.ts` | The push sink, `toPushText`, and the ACL-intersected target list | | `lib/src/remote/host/activation.ts` | Arms the push sink for the lifetime of the remote Host (start, stop, re-enroll) | | `lib/src/lib/push-devices.ts` | Renderer-only store of the devices a push would reach, read by the settings dialog | @@ -265,6 +271,7 @@ Alert-specific robustness requirements: multiple Sessions ring independently; mi | `lib/src/lib/workspace-union.ts` | `computeWorkspaceUnion` projection | | `lib/src/components/bell-icon-class.ts` | Bell tilt/animation mapping from public status | | `lib/src/components/wall/TerminalPaneHeader.tsx` | Bell button, TODO pill, notification preview | +| `lib/src/components/wall/AlertSpeechIndicator.tsx` | Whole-Pane `SPEAKING` / `SPOKEN` treatment | | `lib/src/components/TodoAlertDialog.tsx` | TODO + WATCHING-rule switches, notification detail, watched-command list | | `lib/src/components/AlertSettingsDialog.tsx` | App-global Alarm settings: rule list, inactivity timeout, spoken alarms, push notifications | | `lib/src/components/WatchedCommandList.tsx` | The WATCHING rule set with per-rule remove, shared by both dialogs | diff --git a/docs/specs/layout.md b/docs/specs/layout.md index bbf07e14..fe322571 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -110,6 +110,10 @@ The alert bell and TODO pill are defined in `docs/specs/alert.md` (visual states The pane body paints `--color-terminal-bg` on the React pane wrapper and the `TerminalPane` mount point. The persistent xterm host element, `.xterm-screen`, and xterm scroll container are also painted with the concrete background from `getTerminalTheme()`. This is intentional: xterm.js only paints its own rendered terminal surface, and integer row fitting can leave a sub-row remainder at the bottom of the pane. The host background must match the terminal screen exactly and clip to the pane's shared rounded bottom corners so the terminal surface reaches the selection overlay cleanly. +### Spoken-alarm overlay + +A terminal Session with transient speech-delivery state gets one pointer-transparent overlay spanning its whole Lath leaf, including header and body; browser surfaces never render it. It sits at `z-index: 25`, above the header/content but below the `z-index: 30` sashes, and follows the Pane's full rounded rectangle. `SPEAKING` uses a 5px inset, a 20% contrast wash, and a label 34px from the Pane top (immediately below the 30px header); the whole treatment pulses when motion is allowed. `SPOKEN` uses a static 3px inset, a 10% wash, and the same label. Both use the runtime `--color-alarm-vs-terminal` contrast pick. The overlay never intercepts pointer/focus routing or changes leaf geometry. Behavior and clearing rules belong to `docs/specs/alert.md`. Source of truth: `AlertSpeechIndicator.tsx`, mounted for terminal leaves by `LathHost.tsx`. + ### Pane header responsive sizing The header adapts to available width via ResizeObserver in three tiers: @@ -126,9 +130,9 @@ Below the content area is the baseboard (`h-7`, 28px). It is visible by default `Wall` accepts `showBaseboard={false}` for constrained embedders such as the website's mobile Pocket playground, where a separate bottom navigation owns the area below the terminal and door workflows are outside the prototype scope. The main app shell keeps the default `showBaseboard=true`. -The far right of the baseboard is a single flex cluster, right-aligned as a unit: the `N more →` overflow arrow, then the host-supplied `notice` slot (standalone puts the update banner there), then an always-present **Alarm settings** button opening the dialog specified in `docs/specs/alert.md` → Alarm settings. Every baseboard-level button shares one class constant in `Baseboard.tsx`. The cluster's always-present part is measured and subtracted from the door-fitting budget below; the overflow arrow stays out of that measurement because its presence is an *output* of the fit, so measuring it would feed back into its own input. +The far right of the baseboard is a single flex cluster, right-aligned as a unit: the `N more →` overflow arrow, then the host-supplied `notice` slot (standalone puts the update banner there), then three always-present 24px **Alarm settings** controls. The first is a 16px speaker/slashed-speaker reflecting spoken alarms enabled/disabled; the second is a 16px ringing-bell/slashed-bell reflecting push notifications enabled/disabled; the third is the 16px sliders icon for general settings. Shape and accessible text both carry each state, so the status does not rely on color. All three open the same dialog specified in `docs/specs/alert.md` → Alarm settings; the status controls do not toggle settings directly. Every baseboard-level button shares one class constant in `Baseboard.tsx`. The cluster's always-present part is measured and subtracted from the door-fitting budget below; the overflow arrow stays out of that measurement because its presence is an *output* of the fit, so measuring it would feed back into its own input. -When a session is minimized, it becomes a **door** on the baseboard. The door displays the same derived terminal label as the pane header, a TODO badge (if set), and an alert bell icon with activity dot. It uses the bottom edge of the window as its bottom border, with left, top, and right borders using the shared terminal top radius from `lib/src/components/design.tsx` — resembling a mouse hole and matching pane rounding. Door dimensions: `min-w-[68px] max-w-[220px] h-6`. +When a session is minimized, it becomes a **door** on the baseboard. The door displays the same derived terminal label as the pane header, a TODO badge (if set), and an alert bell icon with activity dot. Transient speech state has visual priority over that compact badge cluster: `SPEAKING` shows a speaker plus label while inverting and pulsing the whole Door; `SPOKEN` shows the speaker plus label with a static 2px contrast inset. It uses the bottom edge of the window as its bottom border, with left, top, and right borders using the shared terminal top radius from `lib/src/components/design.tsx` — resembling a mouse hole and matching pane rounding. Door dimensions: `min-w-[68px] max-w-[220px] h-6`. ### Door interaction @@ -142,7 +146,7 @@ When a session is minimized, it becomes a **door** on the baseboard. The door di Doors are measured in a hidden off-screen container first: -- Subtract the measured right cluster (notice + alarm settings) and its gap from the available width before fitting anything — that space is never available to doors. +- Subtract the measured right cluster (notice + the three alarm-settings controls) and its gap from the available width before fitting anything — that space is never available to doors. - If they all fit, display them all. If there is remaining space, show the keyboard shortcut hint. - If they do not all fit: - Reserve space for a `N more →` button on the right edge @@ -474,6 +478,7 @@ The refill adopts the replacement (`selectPane`) only when the current selection | `lib/src/components/Wall.tsx` | Main layout orchestrator: selected mode/state, session actions, minimize/reattach, provider composition | | `lib/src/components/wall/wall-types.ts` / `wall-context.tsx` | Shared Wall types and React contexts used by Wall, pane headers, panels, overlays, and the baseboard | | `lib/src/components/wall/LathHost.tsx` | The tiling engine's HTML adapter: leaf divs, sashes, the pane/door drag gesture, and imperative animator frame application. Engine internals are mapped in `docs/specs/tiling-engine.md`. | +| `lib/src/components/wall/AlertSpeechIndicator.tsx` | Pointer-transparent whole-Pane `SPEAKING` / `SPOKEN` overlay | | `lib/src/components/wall/TerminalPanel.tsx` | Pane body wrapper; registers the pane's DOM element (`usePaneChrome`) | | `lib/src/components/wall/TerminalPaneHeader.tsx` | Pane header with rename, alert/TODO, mouse override, split/zoom/minimize/kill controls, and the right-click context menu | | `lib/src/components/wall/PaneHeaderContextMenu.tsx` | Pane-header right-click menu: the `surface:N` handle plus the pane's bound TCP ports; a port click connects it to the default browser (`docs/specs/dor-browser.md`) | @@ -486,7 +491,7 @@ The refill adopts the replacement (`selectPane`) only when the current selection | `lib/src/components/wall/use-session-persistence.ts` | Debounced layout/session save, flush requests, pagehide, PTY exit, file-drop paste routing | | `lib/src/components/wall/use-dor-control.ts` | The `dor` CLI's webview control-plane hook (`useDorControl`): the `dormouse:control-request` handler for `surface.*` methods plus its surface-resolution/param-coercion/command-quoting helpers (`docs/specs/dor-cli.md`) | | `lib/src/components/wall/use-window-focused.ts` | Window focus tracking hook for header and selection overlay dimming | -| `lib/src/components/Baseboard.tsx` | Always-visible bottom strip with door components, overflow arrows, shortcut hints, and the right cluster (notice slot + alarm settings button) | +| `lib/src/components/Baseboard.tsx` | Always-visible bottom strip with door components, overflow arrows, shortcut hints, and the right cluster (notice slot + three alarm settings/status buttons) | | `lib/src/components/Door.tsx` | Individual door element — mouse-hole styled button with alert/TODO indicators | | `lib/src/components/TerminalPane.tsx` | Thin xterm.js mount point — mounts/unmounts persistent session elements | | `lib/src/lib/terminal-registry.ts` | Public facade preserving registry imports | diff --git a/docs/specs/theme.md b/docs/specs/theme.md index e672d97d..6f4e0cda 100644 --- a/docs/specs/theme.md +++ b/docs/specs/theme.md @@ -52,7 +52,7 @@ chrome in `lib/src/remote/pocket-app/App.tsx`. Source of truth: `lib/src/theme.css` defines token→VSCode-key bindings. The runtime-picked `--color-door-bg` / `--color-door-fg`, `--color-focus-ring`, and the per-surface alarm tints -(`--color-alarm-vs-{header-active,header-inactive,door}`) are computed by +(`--color-alarm-vs-{header-active,header-inactive,door,terminal}`) are computed by `computeDynamicPalette()` in `lib/src/lib/themes/dynamic-palette.ts` using OKLab distance/chroma helpers from `lib/src/lib/color-contrast.ts`; `useDynamicPalette()` in `lib/src/lib/themes/use-dynamic-palette.ts` publishes @@ -65,9 +65,10 @@ the chosen variables on `document.body`. Public theme helpers are exported from - Focus ring prefers a chromatic `focusBorder`, then a chromatic active-header background, then the highest contrast fallback. - Each alarm tint is plain white or black, picked by the OKLab lightness of - the background the ringing bell sits on (`pickAlarmColor`): the active - header, the inactive header, or the door. The `--vscode-terminal-ansiYellow` - binding in `theme.css` is only the pre-publish baseline. Because + the background the alert treatment sits on (`pickAlarmColor`): the active + header, the inactive header, the door, or the terminal body. The terminal + variant drives the whole-Pane spoken-alarm overlay. The + `--vscode-terminal-ansiYellow` binding in `theme.css` is only the pre-publish baseline. Because `--color-alarm-vs-door` derives from `--color-door-bg`, which the same pass computes, the first pass after a theme change reads the previous door bg — the `MutationObserver` re-fires on the pass's own `body.style` write, so the @@ -111,7 +112,7 @@ state saying a theme is active while xterm.js sees fallback colors. declarations so Tailwind can generate utility classes, but treat the body-level declarations as the runtime source of truth. Dynamic palette tokens (`--color-door-bg`, `--color-door-fg`, -`--color-focus-ring`, and the three `--color-alarm-vs-*` tokens) also have +`--color-focus-ring`, and the four `--color-alarm-vs-*` tokens) also have body-level baseline bindings matching the `@theme` declarations, so direct CSS-var consumers such as the mobile gesture SVG — and a ringing bell before the first dynamic pass — render visibly before `useDynamicPalette()` publishes @@ -179,7 +180,7 @@ defines Storybook's default simulated host theme, with fallback to the first bundled theme so a renamed or removed bundle cannot leave stories without theme vars. The Storybook preview decorator also computes and publishes the dynamic palette -vars (door pair, focus ring, and the `--color-alarm-vs-*` tints) through the +vars (door pair, focus ring, and the four `--color-alarm-vs-*` tints) through the shared `computeDynamicPalette()` helper, matching the runtime `useDynamicPalette()` hook for stories that render doors, baseboards, focus rings, or ringing bells outside a full Wall instance. diff --git a/lib/.storybook/preview.ts b/lib/.storybook/preview.ts index c2642897..415cdff4 100644 --- a/lib/.storybook/preview.ts +++ b/lib/.storybook/preview.ts @@ -23,6 +23,11 @@ import { type TerminalPaneState, } from '../src/lib/terminal-registry'; import { computeDynamicPalette } from '../src/lib/themes/dynamic-palette'; +import { + clearAllAlertSpeechStates, + setAlertSpeechState, + type AlertSpeechState, +} from '../src/lib/alert-speech-state'; import { VSCODE_THEMES, VSCODE_THEME_TYPES } from './themes'; import { cfg } from '../src/cfg'; @@ -60,6 +65,7 @@ const DYNAMIC_PALETTE_VARS = [ '--color-alarm-vs-header-active', '--color-alarm-vs-header-inactive', '--color-alarm-vs-door', + '--color-alarm-vs-terminal', ] as const; const PREFERRED_STORYBOOK_THEME = 'Light (Visual Studio)'; const FIRST_STORYBOOK_THEME = Object.keys(VSCODE_THEMES)[0] ?? ''; @@ -208,6 +214,9 @@ const preview: Preview = { const primedPushDevices = context.parameters?.primedPushDevices as | PushDevicesState | undefined; + const primedAlertSpeech = context.parameters?.primedAlertSpeech as + | Record + | undefined; const platform = fakePlatform as FakePtyAdapter; if (scenario) platform.setDefaultScenario(scenario); @@ -228,6 +237,10 @@ const preview: Preview = { // Alarm dialog's device line names one, and every other story resets // to `no-host`. setPushDevices(primedPushDevices ?? { status: 'no-host', devices: [] }); + clearAllAlertSpeechStates(); + for (const [id, state] of Object.entries(primedAlertSpeech ?? {})) { + setAlertSpeechState(id, state); + } clearPrimedActivity(); for (const id of getTerminalPaneStateSnapshot().keys()) { removeTerminalPaneState(id); @@ -260,6 +273,7 @@ const preview: Preview = { applyWatchedCommands([]); applyAlertSettingsFromHost(undefined); resetPushDevices(); + clearAllAlertSpeechStates(); clearPrimedActivity(); for (const id of getTerminalPaneStateSnapshot().keys()) { removeTerminalPaneState(id); @@ -267,7 +281,7 @@ const preview: Preview = { platform.clearDefaultScenario(); disposeAllSessions(); }; - }, [platform, primedSessionState, primedTerminalState, primedWatchedCommands, primedAlertSettings, primedPushDevices]); + }, [platform, primedSessionState, primedTerminalState, primedWatchedCommands, primedAlertSettings, primedPushDevices, primedAlertSpeech]); return createElement(Story); }, diff --git a/lib/src/components/Baseboard.test.tsx b/lib/src/components/Baseboard.test.tsx new file mode 100644 index 00000000..065ee2e5 --- /dev/null +++ b/lib/src/components/Baseboard.test.tsx @@ -0,0 +1,67 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../lib/platform', () => ({ + IS_MAC: false, + getPlatform: () => ({ alertPublishSettings: vi.fn() }), +})); + +import { Baseboard } from './Baseboard'; +import { applyAlertSettingsFromHost, DEFAULT_ALERT_SETTINGS } from '../lib/alert-settings'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + vi.stubGlobal('ResizeObserver', class { + observe() {} + disconnect() {} + }); + applyAlertSettingsFromHost(DEFAULT_ALERT_SETTINGS); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + applyAlertSettingsFromHost(DEFAULT_ALERT_SETTINGS); + vi.unstubAllGlobals(); +}); + +describe('Baseboard alarm settings controls', () => { + it('keeps separate speech, push, and general settings buttons', () => { + act(() => root.render( {}} />)); + + expect(container.querySelectorAll('[data-alarm-setting]')).toHaveLength(3); + expect(container.querySelector('[data-alarm-setting="speech"]')?.getAttribute('aria-label')) + .toContain('disabled'); + expect(container.querySelector('[data-alarm-setting="push"]')?.getAttribute('aria-label')) + .toContain('disabled'); + expect(container.querySelector('[data-alarm-setting="settings"]')).not.toBeNull(); + }); + + it('reflects enabled states and opens the shared dialog from a status button', () => { + applyAlertSettingsFromHost({ + ...DEFAULT_ALERT_SETTINGS, + speakEnabled: true, + pushEnabled: true, + }); + act(() => root.render( {}} />)); + + const speech = container.querySelector('[data-alarm-setting="speech"]'); + const push = container.querySelector('[data-alarm-setting="push"]'); + expect(speech?.getAttribute('aria-label')).toContain('enabled'); + expect(push?.getAttribute('aria-label')).toContain('enabled'); + + act(() => speech?.dispatchEvent(new MouseEvent('click', { bubbles: true }))); + expect(document.querySelector('[role="dialog"]')?.textContent).toContain('Alarm settings'); + }); +}); diff --git a/lib/src/components/Baseboard.tsx b/lib/src/components/Baseboard.tsx index 31bf7383..fe3e0cd9 100644 --- a/lib/src/components/Baseboard.tsx +++ b/lib/src/components/Baseboard.tsx @@ -1,5 +1,13 @@ import { useEffect, useRef, useState, useMemo, useLayoutEffect, useContext, useSyncExternalStore, type ReactNode } from 'react'; -import { CaretLeftIcon, CaretRightIcon, SlidersHorizontalIcon } from '@phosphor-icons/react'; +import { + BellRingingIcon, + BellSlashIcon, + CaretLeftIcon, + CaretRightIcon, + SlidersHorizontalIcon, + SpeakerHighIcon, + SpeakerSlashIcon, +} from '@phosphor-icons/react'; import { AlertSettingsDialog } from './AlertSettingsDialog'; import { Door } from './Door'; import { DialogKeyboardContext, DoorElementsContext } from './wall/wall-context'; @@ -9,15 +17,19 @@ import { buildAppTitleResolver, DEFAULT_ACTIVITY_STATE, getActivitySnapshot, + getAlertSettings, + getAlertSpeechSnapshot, getTerminalPaneStateSnapshot, subscribeToActivity, + subscribeToAlertSettings, + subscribeToAlertSpeech, subscribeToTerminalPaneState, } from '../lib/terminal-registry'; import { createTerminalPaneState, deriveSurfaceLabel, type TerminalPaneState } from '../lib/terminal-state'; /** Shared look for every baseboard-level button (DESIGN.md -> Navigation). */ const BASEBOARD_BUTTON_CLASS = - 'flex h-5 shrink-0 items-center gap-1 rounded px-1.5 pb-px text-sm font-medium font-mono text-muted transition-colors hover:bg-surface-raised hover:text-foreground'; + 'flex h-6 min-w-6 shrink-0 items-center justify-center gap-1 rounded px-1.5 pb-px text-sm font-medium font-mono text-muted transition-colors hover:bg-surface-raised hover:text-foreground'; export interface BaseboardProps { items: DooredItem[]; @@ -32,6 +44,8 @@ export interface BaseboardProps { export function Baseboard({ items, onReattach, notice, onDoorDragStart }: BaseboardProps) { const { elements: doorElements, bumpVersion } = useContext(DoorElementsContext); const activityStates = useSyncExternalStore(subscribeToActivity, getActivitySnapshot); + const speechStates = useSyncExternalStore(subscribeToAlertSpeech, getAlertSpeechSnapshot); + const settings = useSyncExternalStore(subscribeToAlertSettings, getAlertSettings); const terminalStates = useSyncExternalStore(subscribeToTerminalPaneState, getTerminalPaneStateSnapshot); const allPaneStates = useMemo(() => [...terminalStates.values()], [terminalStates]); const appTitleForPane = useMemo( @@ -100,7 +114,7 @@ export function Baseboard({ items, onReattach, notice, onDoorDragStart }: Basebo if (arrowMeasureEl.current) { layoutMetrics.current.arrowWidth = arrowMeasureEl.current.offsetWidth; } - }, [items, activityStates, terminalStates]); + }, [items, activityStates, speechStates, terminalStates]); // Reset startIndex when the set of door items changes (not just count) const itemKey = useMemo(() => items.map(i => i.id).join('\0'), [items]); @@ -197,7 +211,7 @@ export function Baseboard({ items, onReattach, notice, onDoorDragStart }: Basebo title={title} status={activity.status} todo={activity.todo} - + speechState={speechStates.get(item.id)} /> ); })} @@ -232,6 +246,7 @@ export function Baseboard({ items, onReattach, notice, onDoorDragStart }: Basebo title={title} status={activity.status} todo={activity.todo} + speechState={speechStates.get(item.id)} onClick={() => onReattach(item)} onDragPress={onDoorDragStart ? (press) => onDoorDragStart(item, press) : undefined} /> @@ -257,13 +272,41 @@ export function Baseboard({ items, onReattach, notice, onDoorDragStart }: Basebo
{notice} + + + +
diff --git a/lib/src/components/Door.test.tsx b/lib/src/components/Door.test.tsx new file mode 100644 index 00000000..b9851380 --- /dev/null +++ b/lib/src/components/Door.test.tsx @@ -0,0 +1,49 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { Door } from './Door'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); +}); + +describe('Door spoken-alarm state', () => { + it('inverts and animates the whole Door while its Session is speaking', () => { + act(() => root.render( + , + )); + + const door = container.querySelector('[data-alert-speech-state="speaking"]'); + expect(door?.className).toContain('bg-alarm-vs-door'); + expect(door?.className).toContain('animate-speech-alarm-pulse'); + expect(door?.textContent).toContain('SPEAKING'); + expect(door?.textContent).not.toContain('TODO'); + expect(door?.getAttribute('aria-label')).toBe('build-server, speaking'); + }); + + it('shows a static high-contrast SPOKEN label afterwards', () => { + act(() => root.render( + , + )); + + const door = container.querySelector('[data-alert-speech-state="spoken"]'); + expect(door?.className).toContain('inset_0_0_0_2px'); + expect(door?.className).not.toContain('animate-speech-alarm-pulse'); + expect(door?.textContent).toContain('SPOKEN'); + }); +}); diff --git a/lib/src/components/Door.tsx b/lib/src/components/Door.tsx index 467c1827..24435c15 100644 --- a/lib/src/components/Door.tsx +++ b/lib/src/components/Door.tsx @@ -1,15 +1,21 @@ import { type PointerEvent as ReactPointerEvent } from 'react'; -import { BellIcon } from '@phosphor-icons/react'; -import type { SessionStatus, TodoState } from '../lib/terminal-registry'; +import { BellIcon, SpeakerHighIcon } from '@phosphor-icons/react'; +import type { AlertSpeechState, SessionStatus, TodoState } from '../lib/terminal-registry'; import { useTodoPillContent } from './TodoPillBody'; import { bellIconClass } from './bell-icon-class'; -import { TERMINAL_TOP_RADIUS_CLASS, TODO_PILL_TRACKING_CLASS } from './design'; +import { + ALERT_SPEAKING_ANIMATION_CLASS, + ALERT_SPEECH_TRACKING_CLASS, + TERMINAL_TOP_RADIUS_CLASS, + TODO_PILL_TRACKING_CLASS, +} from './design'; export interface DoorProps { doorId?: string; title: string; status?: SessionStatus; todo?: TodoState; + speechState?: AlertSpeechState; onClick?: () => void; /** When provided, a primary-button press reports its start point and the Wall begins * an (inactive) LathHost drag — LathHost owns the threshold, click suppression, and @@ -23,12 +29,14 @@ export function Door({ title, status = 'WATCHING_DISABLED', todo = false, + speechState, onClick, onDragPress, }: DoorProps) { const showBell = status !== 'WATCHING_DISABLED'; const alertRinging = status === 'ALERT_RINGING'; const todoPill = useTodoPillContent(todo); + const speechLabel = speechState === 'speaking' ? 'SPEAKING' : speechState === 'spoken' ? 'SPOKEN' : null; const onPointerDown = onDragPress ? (e: ReactPointerEvent): void => { @@ -43,17 +51,35 @@ export function Door({ className={[ 'relative flex h-6 max-w-[220px] min-w-[68px] items-center gap-2 overflow-hidden px-2.5', TERMINAL_TOP_RADIUS_CLASS, - 'bg-door-bg text-door-fg', + speechState === 'speaking' + ? `bg-alarm-vs-door text-door-bg ${ALERT_SPEAKING_ANIMATION_CLASS}` + : 'bg-door-bg text-door-fg', + speechState === 'spoken' + ? 'shadow-[inset_0_0_0_2px_var(--color-alarm-vs-door)]' + : '', 'text-sm font-medium font-mono', ].join(' ')} onClick={onClick} onPointerDown={onPointerDown} - title={title} + title={speechLabel ? `${title} — ${speechLabel.toLowerCase()}` : title} + aria-label={speechLabel ? `${title}, ${speechLabel.toLowerCase()}` : undefined} + data-alert-speech-state={speechState} > {title} - {(todoPill.visible || showBell) && ( + {speechLabel ? ( + + + {speechLabel} + + ) : (todoPill.visible || showBell) && ( {todoPill.visible && ( { + clearAllAlertSpeechStates(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + act(() => root.render()); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + clearAllAlertSpeechStates(); +}); + +describe('AlertSpeechIndicator', () => { + it('renders a loud animated SPEAKING state over the whole Pane', () => { + act(() => setAlertSpeechState('pty-1', 'speaking')); + + const indicator = container.querySelector('[data-alert-speech-state="speaking"]'); + expect(indicator).not.toBeNull(); + expect(indicator?.textContent).toContain('SPEAKING'); + expect(indicator?.className).toContain('animate-speech-alarm-pulse'); + expect(indicator?.className).toContain('inset_0_0_0_5px'); + expect(indicator?.getAttribute('aria-label')).toBe('Terminal is speaking'); + }); + + it('keeps a static SPOKEN treatment until the state is cleared', () => { + act(() => setAlertSpeechState('pty-1', 'spoken')); + + const indicator = container.querySelector('[data-alert-speech-state="spoken"]'); + expect(indicator?.textContent).toContain('SPOKEN'); + expect(indicator?.className).not.toContain('animate-speech-alarm-pulse'); + expect(indicator?.className).toContain('inset_0_0_0_3px'); + + act(() => clearAllAlertSpeechStates()); + expect(container.querySelector('[data-alert-speech-state]')).toBeNull(); + }); +}); diff --git a/lib/src/components/wall/AlertSpeechIndicator.tsx b/lib/src/components/wall/AlertSpeechIndicator.tsx new file mode 100644 index 00000000..6b7aaf04 --- /dev/null +++ b/lib/src/components/wall/AlertSpeechIndicator.tsx @@ -0,0 +1,52 @@ +import { useSyncExternalStore } from 'react'; +import { SpeakerHighIcon } from '@phosphor-icons/react'; +import { + getAlertSpeechSnapshot, + subscribeToAlertSpeech, +} from '../../lib/terminal-registry'; +import { + ALERT_SPEAKING_ANIMATION_CLASS, + ALERT_SPEECH_TRACKING_CLASS, +} from '../design'; + +/** + * Very loud, pointer-transparent delivery state over one terminal Pane. + * + * The overlay spans the whole Lath leaf (header and body), but stays below + * sashes and never participates in focus or pointer routing. + */ +export function AlertSpeechIndicator({ sessionId }: { sessionId: string }) { + const speech = useSyncExternalStore(subscribeToAlertSpeech, getAlertSpeechSnapshot); + const state = speech.get(sessionId); + if (!state) return null; + + const speaking = state === 'speaking'; + const label = speaking ? 'SPEAKING' : 'SPOKEN'; + + return ( +
+
+ + {label} +
+
+ ); +} diff --git a/lib/src/components/wall/LathHost.tsx b/lib/src/components/wall/LathHost.tsx index af6778f9..480aaa5c 100644 --- a/lib/src/components/wall/LathHost.tsx +++ b/lib/src/components/wall/LathHost.tsx @@ -42,6 +42,7 @@ import { TerminalPanel } from './TerminalPanel'; import { BrowserPanel } from './BrowserPanel'; import { TerminalPaneHeader } from './TerminalPaneHeader'; import { SurfacePaneHeader } from './SurfacePaneHeader'; +import { AlertSpeechIndicator } from './AlertSpeechIndicator'; /** Widened pointer target over each (thin) sash band, in px. */ const SASH_HIT = 8; @@ -132,7 +133,8 @@ type LeafCallbacks = { * identity (id / meta / resolved components / the stable header-press handler), so a * geometry-only frame — a sash-drag preview or a resize commit re-renders the * positioned wrapper — never re-renders the header or body. Returned as a fragment so - * the header/body stay direct flex children of `.lath-leaf`. A mounted leaf is always + * the header/body and pointer-transparent speech overlay stay direct children of + * `.lath-leaf`. A mounted leaf is always * engine-visible, so the pane props carry no visibility flag (docs/specs/ * tiling-engine.md → "Pane props contract"). */ const LathLeafContent = memo(function LathLeafContent({ @@ -156,6 +158,7 @@ const LathLeafContent = memo(function LathLeafContent({ {Tab ? : null}
{Body ? : null}
+ {meta?.component === 'terminal' ? : null} ); }); diff --git a/lib/src/lib/alert-speech-state.ts b/lib/src/lib/alert-speech-state.ts new file mode 100644 index 00000000..aa56b44b --- /dev/null +++ b/lib/src/lib/alert-speech-state.ts @@ -0,0 +1,47 @@ +/** + * Renderer-local delivery state for spoken alarms. + * + * This deliberately does not live in the persisted Session Activity machine: + * `speaking` describes the browser speech engine right now, and `spoken` is only + * a transient acknowledgement that remains while the originating ring is still + * unresolved. Restores and reconnects must never recreate either state. + */ + +export type AlertSpeechState = 'speaking' | 'spoken'; + +let snapshot = new Map(); +const listeners = new Set<() => void>(); + +export function getAlertSpeechState(sessionId: string): AlertSpeechState | null { + return snapshot.get(sessionId) ?? null; +} + +/** Stable-identity snapshot for `useSyncExternalStore`. */ +export function getAlertSpeechSnapshot(): Map { + return snapshot; +} + +export function subscribeToAlertSpeech(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function setAlertSpeechState(sessionId: string, state: AlertSpeechState): void { + if (snapshot.get(sessionId) === state) return; + snapshot = new Map(snapshot); + snapshot.set(sessionId, state); + listeners.forEach((listener) => listener()); +} + +export function clearAlertSpeechState(sessionId: string): void { + if (!snapshot.has(sessionId)) return; + snapshot = new Map(snapshot); + snapshot.delete(sessionId); + listeners.forEach((listener) => listener()); +} + +export function clearAllAlertSpeechStates(): void { + if (snapshot.size === 0) return; + snapshot = new Map(); + listeners.forEach((listener) => listener()); +} diff --git a/lib/src/lib/alert-speech.test.ts b/lib/src/lib/alert-speech.test.ts index 18bff7ba..6536d470 100644 --- a/lib/src/lib/alert-speech.test.ts +++ b/lib/src/lib/alert-speech.test.ts @@ -5,6 +5,7 @@ vi.mock('./platform', () => ({ })); import { startAlertSpeech, toSpokenText } from './alert-speech'; +import { getAlertSpeechState } from './alert-speech-state'; import { applyAlertSettingsFromHost, DEFAULT_ALERT_SETTINGS } from './alert-settings'; import { clearPrimedActivity, primeActivity } from './session-activity-store'; import type { SessionStatus } from './activity-monitor'; @@ -15,13 +16,30 @@ const SPEAK_DELAY_MS = 10_000; /** Utterances passed to the stubbed Web Speech API, in order. */ let spoken: string[]; +let utterances: StubUtterance[]; let stopSpeech: (() => void) | null = null; +interface StubUtterance { + text: string; + onstart: (() => void) | null; + onend: (() => void) | null; + onerror: (() => void) | null; +} + function stubSpeechSynthesis(): void { spoken = []; - vi.stubGlobal('speechSynthesis', { speak: (u: { text: string }) => spoken.push(u.text) }); + utterances = []; + vi.stubGlobal('speechSynthesis', { + speak: (utterance: StubUtterance) => { + spoken.push(utterance.text); + utterances.push(utterance); + }, + }); vi.stubGlobal('SpeechSynthesisUtterance', class { text: string; + onstart: (() => void) | null = null; + onend: (() => void) | null = null; + onerror: (() => void) | null = null; constructor(text: string) { this.text = text; } }); } @@ -84,6 +102,11 @@ describe('toSpokenText', () => { expect(toSpokenText('build\u0007done\u001b')).toBe('build done'); }); + it('drops asterisks instead of saying “asterisk” aloud', () => { + expect(toSpokenText('eight *')).toBe('eight'); + expect(toSpokenText('build*finished')).toBe('build finished'); + }); + it('collapses the whitespace its own substitutions create', () => { expect(toSpokenText(' ')).toBe('a b'); }); @@ -185,11 +208,74 @@ describe('spoken alarms', () => { expect(spoken).toEqual(['terminal']); }); + it('publishes SPEAKING on actual start, then SPOKEN on end', () => { + start(); + ring('pty-1'); + vi.advanceTimersByTime(SPEAK_DELAY_MS); + + expect(getAlertSpeechState('pty-1')).toBeNull(); + utterances[0].onstart?.(); + expect(getAlertSpeechState('pty-1')).toBe('speaking'); + + utterances[0].onend?.(); + expect(getAlertSpeechState('pty-1')).toBe('spoken'); + }); + + it('keeps SPOKEN through unrelated churn while the ring remains unresolved', () => { + start(); + ring('pty-1'); + vi.advanceTimersByTime(SPEAK_DELAY_MS); + utterances[0].onstart?.(); + utterances[0].onend?.(); + + primeActivity('pty-1', { status: 'ALERT_RINGING', todo: true }); + setStatus('another-pane', 'BUSY'); + expect(getAlertSpeechState('pty-1')).toBe('spoken'); + }); + + it('clears delivery state when attention or another deliberate action resolves the ring', () => { + start(); + ring('pty-1'); + vi.advanceTimersByTime(SPEAK_DELAY_MS); + utterances[0].onstart?.(); + expect(getAlertSpeechState('pty-1')).toBe('speaking'); + + setStatus('pty-1', 'NOTHING_TO_SHOW'); + expect(getAlertSpeechState('pty-1')).toBeNull(); + + // The engine can finish after the user attends. Its stale callback must not + // resurrect HAS SPOKEN after the resolution. + utterances[0].onend?.(); + expect(getAlertSpeechState('pty-1')).toBeNull(); + }); + + it('does not publish a queued utterance that starts after the ring was resolved', () => { + start(); + ring('pty-1'); + vi.advanceTimersByTime(SPEAK_DELAY_MS); + + setStatus('pty-1', 'NOTHING_TO_SHOW'); + utterances[0].onstart?.(); + utterances[0].onend?.(); + expect(getAlertSpeechState('pty-1')).toBeNull(); + }); + + it('records SPOKEN after an engine error if the utterance really began', () => { + start(); + ring('pty-1'); + vi.advanceTimersByTime(SPEAK_DELAY_MS); + utterances[0].onstart?.(); + utterances[0].onerror?.(); + + expect(getAlertSpeechState('pty-1')).toBe('spoken'); + }); + it('no-ops when the host webview has no speech backend', () => { vi.stubGlobal('speechSynthesis', undefined); start(); ring('pty-1'); expect(() => vi.advanceTimersByTime(60_000)).not.toThrow(); + expect(getAlertSpeechState('pty-1')).toBeNull(); }); }); diff --git a/lib/src/lib/alert-speech.ts b/lib/src/lib/alert-speech.ts index f5fd0f51..0a4514fc 100644 --- a/lib/src/lib/alert-speech.ts +++ b/lib/src/lib/alert-speech.ts @@ -1,5 +1,12 @@ import { getAlertSettings } from './alert-settings'; import { watchUnattendedRings } from './alert-ring-watch'; +import { + clearAlertSpeechState, + clearAllAlertSpeechStates, + getAlertSpeechSnapshot, + setAlertSpeechState, +} from './alert-speech-state'; +import { getActivity, getActivitySnapshot, subscribeToActivity } from './session-activity-store'; import { deriveSessionLabel } from './session-label'; /** @@ -15,6 +22,11 @@ import { deriveSessionLabel } from './session-label'; * when they win label derivation; `ActivityNotification` fields are not chosen * as a separate speech payload. * + * Actual utterance callbacks publish the transient per-Session `speaking` / + * `spoken` state rendered by Panes and Doors. It is intentionally separate from + * persisted Activity: resolving the ring clears it, and a restore never recreates + * evidence that this renderer spoke. + * * `speak()` is the single seam a future native `PlatformAdapter.speak?()` would * slot into for hosts whose webview has no speech backend (Tauri on * Linux/WebKitGTK). @@ -33,15 +45,15 @@ const SPEECH_LIMIT = 120; * any program could permanently disable spoken alarms for the session by * putting a `<` in its title (`docs/specs/alert.md` -> Text And Security). * - * Markup metacharacters become spaces rather than being deleted, so `a&]/g, ' ') + .replace(/[<>&*]/g, ' ') .replace(/\s+/g, ' ') .trim(); // Capped in code points, matching `boundedPushText`: a cut mid-surrogate @@ -49,26 +61,108 @@ export function toSpokenText(label: string): string { return Array.from(cleaned).slice(0, SPEECH_LIMIT).join('').trim() || 'terminal'; } -function speak(text: string): void { +interface SpeechLifecycle { + readonly onStart: () => void; + readonly onEnd: () => void; + readonly onError: () => void; +} + +function speak(text: string, lifecycle: SpeechLifecycle): SpeechSynthesisUtterance | null { const synth = globalThis.speechSynthesis; // Absent in jsdom and in webviews with no speech backend — staying silent is // the correct degradation, not an error. - if (!synth || typeof globalThis.SpeechSynthesisUtterance !== 'function') return; + if (!synth || typeof globalThis.SpeechSynthesisUtterance !== 'function') return null; + + let utterance: SpeechSynthesisUtterance; try { - synth.speak(new globalThis.SpeechSynthesisUtterance(toSpokenText(text))); + utterance = new globalThis.SpeechSynthesisUtterance(toSpokenText(text)); + utterance.onstart = lifecycle.onStart; + utterance.onend = lifecycle.onEnd; + utterance.onerror = lifecycle.onError; + synth.speak(utterance); + return utterance; } catch { // A speech engine that refuses the utterance must never break the alert path. + return null; } } /** * Watch the activity store for fresh rings and speak the unattended ones. - * Returns a disposer that cancels every pending utterance. + * Returns a disposer that cancels pending ring timers and detaches delivery + * callbacks from utterances already handed to the engine. */ export function startAlertSpeech(): () => void { - return watchUnattendedRings({ + // A callback from an old or already-attended utterance must not overwrite the + // state of a newer ring for the same Session. The opaque token makes every + // utterance generation distinct without exposing engine objects to the store. + const currentToken = new Map(); + const utterances = new Set(); + clearAllAlertSpeechStates(); + + const settle = (sessionId: string, token: object, utterance: SpeechSynthesisUtterance): void => { + utterances.delete(utterance); + utterance.onstart = null; + utterance.onend = null; + utterance.onerror = null; + if (currentToken.get(sessionId) !== token) return; + currentToken.delete(sessionId); + // An utterance that really started counts as spoken even if the engine later + // reports an error: the user may already have heard part of it. + if (getActivity(sessionId).status === 'ALERT_RINGING') { + setAlertSpeechState(sessionId, 'spoken'); + } else { + clearAlertSpeechState(sessionId); + } + }; + + const stopRingWatch = watchUnattendedRings({ enabled: () => getAlertSettings().speakEnabled, delayMs: () => getAlertSettings().speakDelayMs, - fire: (id) => speak(deriveSessionLabel(id)), + fire: (sessionId) => { + const token = {}; + let utterance: SpeechSynthesisUtterance | null = null; + const lifecycle: SpeechLifecycle = { + onStart: () => { + // The engine can queue several Sessions. Re-check at the actual start, + // not merely when `speak()` accepted the queued utterance. + if (getActivity(sessionId).status !== 'ALERT_RINGING') return; + currentToken.set(sessionId, token); + setAlertSpeechState(sessionId, 'speaking'); + }, + onEnd: () => { + if (utterance) settle(sessionId, token, utterance); + }, + onError: () => { + if (utterance) settle(sessionId, token, utterance); + }, + }; + utterance = speak(deriveSessionLabel(sessionId), lifecycle); + if (utterance) utterances.add(utterance); + }, }); + + const clearResolvedSpeech = (): void => { + const activity = getActivitySnapshot(); + for (const sessionId of getAlertSpeechSnapshot().keys()) { + if (activity.get(sessionId)?.status === 'ALERT_RINGING') continue; + currentToken.delete(sessionId); + clearAlertSpeechState(sessionId); + } + }; + clearResolvedSpeech(); + const unsubscribeActivity = subscribeToActivity(clearResolvedSpeech); + + return () => { + stopRingWatch(); + unsubscribeActivity(); + currentToken.clear(); + for (const utterance of utterances) { + utterance.onstart = null; + utterance.onend = null; + utterance.onerror = null; + } + utterances.clear(); + clearAllAlertSpeechStates(); + }; } diff --git a/lib/src/lib/terminal-registry.ts b/lib/src/lib/terminal-registry.ts index 8bb5a296..bd5ab2c6 100644 --- a/lib/src/lib/terminal-registry.ts +++ b/lib/src/lib/terminal-registry.ts @@ -1,5 +1,6 @@ export type { SessionStatus } from './activity-monitor'; export type { TodoState } from './alert-manager'; +export type { AlertSpeechState } from './alert-speech-state'; export type { ActivityState, AlertButtonActionResult } from './session-activity-store'; export type { TerminalEntry, TerminalOverlayDims } from './terminal-store'; export type { @@ -87,6 +88,12 @@ export type { PushDevice, PushDevicesState } from './push-devices'; export { deriveSessionLabel } from './session-label'; +export { + getAlertSpeechState, + getAlertSpeechSnapshot, + subscribeToAlertSpeech, +} from './alert-speech-state'; + export { applyTerminalSemanticEvents, applyTerminalSemanticEventsByPtyId, diff --git a/lib/src/lib/themes/dynamic-palette.ts b/lib/src/lib/themes/dynamic-palette.ts index a987c0f6..bc4a8f94 100644 --- a/lib/src/lib/themes/dynamic-palette.ts +++ b/lib/src/lib/themes/dynamic-palette.ts @@ -68,6 +68,7 @@ export interface DynamicPaletteVars { '--color-alarm-vs-header-active'?: string; '--color-alarm-vs-header-inactive'?: string; '--color-alarm-vs-door'?: string; + '--color-alarm-vs-terminal'?: string; } export function computeDynamicPalette( @@ -117,6 +118,10 @@ export function computeDynamicPalette( if (doorRgb) { result['--color-alarm-vs-door'] = pickAlarmColor(doorRgb); } + const terminalRgb = rgbOfVar('--color-terminal-bg'); + if (terminalRgb) { + result['--color-alarm-vs-terminal'] = pickAlarmColor(terminalRgb); + } return result; } diff --git a/lib/src/lib/themes/use-dynamic-palette.ts b/lib/src/lib/themes/use-dynamic-palette.ts index 503d2af1..92ff70b3 100644 --- a/lib/src/lib/themes/use-dynamic-palette.ts +++ b/lib/src/lib/themes/use-dynamic-palette.ts @@ -32,6 +32,7 @@ export function useDynamicPalette(): void { document.body.style.removeProperty('--color-alarm-vs-header-active'); document.body.style.removeProperty('--color-alarm-vs-header-inactive'); document.body.style.removeProperty('--color-alarm-vs-door'); + document.body.style.removeProperty('--color-alarm-vs-terminal'); }; }, []); } diff --git a/lib/src/stories/AlertSpeechIndicator.stories.tsx b/lib/src/stories/AlertSpeechIndicator.stories.tsx new file mode 100644 index 00000000..fd040841 --- /dev/null +++ b/lib/src/stories/AlertSpeechIndicator.stories.tsx @@ -0,0 +1,42 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { AlertSpeechIndicator } from '../components/wall/AlertSpeechIndicator'; + +const SESSION_ID = 'speech-indicator-story'; + +function IndicatorStory() { + return ( +
+
+
+ build-server +
+
+ $ pnpm build +
+ Build completed successfully. +
+ +
+
+ ); +} + +const meta: Meta = { + title: 'Components/AlertSpeechIndicator', + component: IndicatorStory, +}; + +export default meta; +type Story = StoryObj; + +export const Speaking: Story = { + parameters: { + primedAlertSpeech: { [SESSION_ID]: 'speaking' }, + }, +}; + +export const HasSpoken: Story = { + parameters: { + primedAlertSpeech: { [SESSION_ID]: 'spoken' }, + }, +}; diff --git a/lib/src/stories/Baseboard.stories.tsx b/lib/src/stories/Baseboard.stories.tsx index 3ddfe17d..9e5e791f 100644 --- a/lib/src/stories/Baseboard.stories.tsx +++ b/lib/src/stories/Baseboard.stories.tsx @@ -88,6 +88,27 @@ export const OneRingingDoor: Story = { }), }; +export const OneSpeakingDoor: Story = { + args: { + items: oneRingingDoorItems, + }, + parameters: { + ...withState(oneRingingDoorItems, { + p1: { status: 'ALERT_RINGING', todo: true }, + }), + primedAlertSpeech: { p1: 'speaking' }, + }, +}; + +export const AlarmOutputsEnabled: Story = { + args: { + items: [], + }, + parameters: { + primedAlertSettings: { speakEnabled: true, pushEnabled: true }, + }, +}; + export const MixedDoorStates: Story = { args: { items: mixedDoorStateItems, diff --git a/lib/src/stories/Door.stories.tsx b/lib/src/stories/Door.stories.tsx index 31b63853..a1a50540 100644 --- a/lib/src/stories/Door.stories.tsx +++ b/lib/src/stories/Door.stories.tsx @@ -35,6 +35,7 @@ const meta: Meta = { title: { control: 'text' }, status: { control: 'radio', options: ['WATCHING_DISABLED', 'NOTHING_TO_SHOW', 'MIGHT_BE_BUSY', 'BUSY', 'OSC_NOTIF_BUSY', 'COMMAND_EXIT_ARMED', 'MIGHT_NEED_ATTENTION', 'ALERT_RINGING'] }, todo: { control: 'boolean' }, + speechState: { control: 'radio', options: [undefined, 'speaking', 'spoken'] }, width: { control: 'number' }, reducedMotion: { control: 'boolean' }, }, @@ -54,6 +55,8 @@ export const AlertRinging: Story = { args: { status: 'ALERT_RINGING' } }; export const TodoOnly: Story = { args: { todo: true } }; export const TodoAndAlertEnabled: Story = { args: { todo: true, status: 'NOTHING_TO_SHOW' } }; export const TodoAndAlertRinging: Story = { args: { todo: true, status: 'ALERT_RINGING' } }; +export const Speaking: Story = { args: { status: 'ALERT_RINGING', todo: true, speechState: 'speaking' } }; +export const HasSpoken: Story = { args: { status: 'ALERT_RINGING', todo: true, speechState: 'spoken' } }; export const LongTitleWithIndicators: Story = { args: { title: 'my-extremely-long-running-background-process-with-a-very-descriptive-name', diff --git a/lib/src/theme.css b/lib/src/theme.css index e76313ab..bd5c9af8 100644 --- a/lib/src/theme.css +++ b/lib/src/theme.css @@ -83,6 +83,7 @@ --color-alarm-vs-header-active: var(--vscode-terminal-ansiYellow); --color-alarm-vs-header-inactive: var(--vscode-terminal-ansiYellow); --color-alarm-vs-door: var(--vscode-terminal-ansiYellow); + --color-alarm-vs-terminal: var(--vscode-terminal-ansiYellow); /* Inputs — used by ThemePicker */ --color-input-bg: var(--vscode-input-background); @@ -90,6 +91,7 @@ /* Animation */ --animate-bell-ring: bell-ring 800ms ease-in-out infinite; + --animate-speech-alarm-pulse: speech-alarm-pulse 650ms ease-in-out infinite; --animate-shake-x: shake-x 400ms ease-out; } @@ -123,6 +125,7 @@ body { --color-alarm-vs-header-active: var(--vscode-terminal-ansiYellow); --color-alarm-vs-header-inactive: var(--vscode-terminal-ansiYellow); --color-alarm-vs-door: var(--vscode-terminal-ansiYellow); + --color-alarm-vs-terminal: var(--vscode-terminal-ansiYellow); --color-input-bg: var(--vscode-input-background); --color-input-border: var(--vscode-input-border); } @@ -132,6 +135,11 @@ body { 50% { transform: rotate(-45deg); } } +@keyframes speech-alarm-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.55; } +} + @keyframes shake-x { 0%, 100% { translate: 0; } 20% { translate: -6px; }