From 533eaa960aa78db674596f1667cfa7b19505d411 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 20 Sep 2026 00:14:03 -0700 Subject: [PATCH 1/2] Wear the alarm treatment on every ringing Pane, not only spoken ones 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 --- DESIGN.md | 4 +- docs/specs/alert.md | 5 +- docs/specs/alert.rationale.md | 2 + docs/specs/layout.md | 18 +- docs/specs/layout.rationale.md | 2 +- docs/specs/theme.md | 2 +- docs/specs/tiling-engine.md | 2 +- lib/src/components/Baseboard.tsx | 1 + lib/src/components/Door.test.tsx | 19 +- lib/src/components/Door.tsx | 34 ++- lib/src/components/TerminalPane.test.tsx | 2 +- lib/src/components/Wall.test.tsx | 15 ++ lib/src/components/bell-icon-class.ts | 15 -- lib/src/components/design.tsx | 34 ++- .../wall/AlertRingIndicator.test.tsx | 195 ++++++++++++++++++ ...chIndicator.tsx => AlertRingIndicator.tsx} | 73 +++++-- .../wall/AlertSpeechIndicator.test.tsx | 111 ---------- lib/src/components/wall/LathHost.tsx | 4 +- ...ies.tsx => AlertRingIndicator.stories.tsx} | 21 +- lib/src/theme.css | 4 + scripts/spec-word-budgets.json | 2 +- 21 files changed, 391 insertions(+), 174 deletions(-) create mode 100644 lib/src/components/wall/AlertRingIndicator.test.tsx rename lib/src/components/wall/{AlertSpeechIndicator.tsx => AlertRingIndicator.tsx} (50%) delete mode 100644 lib/src/components/wall/AlertSpeechIndicator.test.tsx rename lib/src/stories/{AlertSpeechIndicator.stories.tsx => AlertRingIndicator.stories.tsx} (58%) diff --git a/DESIGN.md b/DESIGN.md index 108a33f65..590e0ad7c 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -137,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): 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. +- **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 alarm treatment stay maximally legible on any surface. ### Fixed Exceptions Every literal color the Host-Theme-Only Rule below permits, in full. Each is here because the surface it paints is not read as part of the theme; a literal anywhere else is a bug. @@ -199,7 +199,7 @@ Doors are the pane-header indicators on the baseboard. The most signature compon - **Dimensions:** `h-6` (24px), `min-w-[68px]`, `max-w-[220px]`; the title button pads `pl-2.5` (10px), `gap-2` between its glyph, title, and badges, ending `pr-2.5` alone or `pr-1` when the notepad button follows it. The notepad button carries the trailing inset itself (`pl-0.5 pr-2`). - **Type:** `text-sm font-medium font-mono`. - **Content:** leading browser-display icon cluster on a browser Surface (`size={12}` each, `gap-0.5` — a wide robot plus the presentation glyph, or the presentation glyph alone for `iframe`; named in the Door's accessible name, `docs/specs/dor-browser.md` → Browser Chrome); 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; trailing notepad button (`size={12}`, `weight="fill"`) when the minimized Surface holds notes. -- **Spoken alarm:** `SPEAKING` inverts and pulses the whole Door and takes the badge slot for its speaker-plus-label — it lasts one utterance. `SPOKEN` persists until the ring is attended, so it keeps a static 2px inset and adds its speaker icon *beside* the TODO pill and bell instead of evicting them. Both carry a speaker icon (shape, not color) and name the state in the accessible name. +- **Alarm:** a plain ring wears a static 2px inset (`--color-alarm-vs-door`) that flashes once on arrival, with no label. `SPEAKING` inverts and pulses the whole Door and takes the badge slot for its speaker-plus-label — it lasts one utterance. `SPOKEN` persists until the ring is attended, so it keeps the same static inset and adds its speaker icon *beside* the TODO pill and bell instead of evicting them. Both speech states carry a speaker icon (shape, not color); all three name the state in the accessible name. - **Hover/Focus:** no decorative hover on the door itself; the focus state is conveyed by the parent pane's selection ring, not by a per-door treatment. The door is a labelled `role="group"` wrapper holding one or two buttons rather than one button — the title button reattaches, the notepad button opens the popover and does not (`docs/specs/notepad.md` → Notepad UI) — and only the notepad button takes the standard `hover:bg-current/10` wash. ### Buttons diff --git a/docs/specs/alert.md b/docs/specs/alert.md index e9cb4a100..2c434a088 100644 --- a/docs/specs/alert.md +++ b/docs/specs/alert.md @@ -385,9 +385,9 @@ Source of truth: `TerminalContext` in `lib/src/components/wall/TerminalContext.t 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** — unlabelled until the speech sink acts, then `SPEAKING` while the engine speaks and `SPOKEN` until the ring resolves. **Must bound the unlabelled pulse to one finite burst per episode, keyed on `ActivityState.episode.id` and clocked from its `startedAt`, so a remount never replays a finished burst** (rationale). **`prefers-reduced-motion` keeps the strong static treatment and suppresses only the pulse**, as does `cfg.alert.ringingPaused` (rationale). Layers, strengths, and sizing belong to `docs/specs/layout.md` → Alarm overlay. -Source of truth: `AlertBell` in `lib/src/components/AlertBell.tsx`; `bellIconClass` in `lib/src/components/bell-icon-class.ts`; `latchRing` in `lib/src/lib/alert-manager.ts`; `dismissSessionAlert` in `lib/src/lib/session-activity-store.ts`; `TerminalContext` in `lib/src/components/wall/TerminalContext.tsx`; `lib/src/components/TodoPillBody.tsx`; `lib/src/components/wall/AlertSpeechIndicator.tsx`. +Source of truth: `AlertBell` in `lib/src/components/AlertBell.tsx`; `bellIconClass` in `lib/src/components/bell-icon-class.ts`; `latchRing` in `lib/src/lib/alert-manager.ts`; `dismissSessionAlert` in `lib/src/lib/session-activity-store.ts`; `TerminalContext` in `lib/src/components/wall/TerminalContext.tsx`; `lib/src/components/TodoPillBody.tsx`; `AlertRingIndicator` in `lib/src/components/wall/AlertRingIndicator.tsx`. ### Door @@ -396,6 +396,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 ringing with no speech state, wear the 2px inset ring `spoken` uses, unlabelled, named `needs attention`; it carries the Pane's one burst per episode - while its Session is `speaking`, replace the compact bell/TODO cluster with the explicit `SPEAKING` label and invert + pulse the whole Door — that state lasts one utterance. `spoken` persists until the ring is attended, so it keeps a static high-contrast inset and adds a speaker icon *alongside* the bell and TODO pill instead of replacing them; those are the baseboard's persistent signals and **must not go dark for an unbounded window** - do not expose a Door-specific alert menu diff --git a/docs/specs/alert.rationale.md b/docs/specs/alert.rationale.md index c10711877..8a552b418 100644 --- a/docs/specs/alert.rationale.md +++ b/docs/specs/alert.rationale.md @@ -114,6 +114,8 @@ Guarding only completion leaves a stale `start` free to replace the active utter **Why `cfg.alert.ringingPaused` suppresses the burst.** It is the Chromatic freeze that pins the bell; even a bounded animation could otherwise snapshot at an arbitrary phase during its first 3.2 seconds. +**Why the unlabelled treatment pulses once per episode.** The whole-Pane treatment is a per-Session animation on a surface far larger than a bell, so an infinite pulse would pay the cost measured above and pay it worse; bounding it buys the entry cue and then stops. The episode — not a track latch — is the key because the episode is the summons the sinks already work from: a second track latching inside one enriches an alarm the user was already shown, and re-flashing the whole Pane for it would read as a new alarm. Running the burst off `episode.startedAt` rather than from mount makes the CSS clock a property of the episode, so minimize → reattach or a Workspace switch lands past an expired burst instead of replaying it, the same trade the bell's mount replay makes in the other direction (its className carries no clock to start from). + ## Text And Security **Why the cold-restore path is not re-sanitized.** Reaching it requires a corrupted or hand-edited session store, and the text is rendered as plain text everywhere, so the residual exposure is layout — a very long or control-bearing string in a preview — rather than markup. diff --git a/docs/specs/layout.md b/docs/specs/layout.md index d082e097f..382d2f7a4 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -96,16 +96,24 @@ The pane body paints `--color-terminal-bg` on the React pane wrapper and the `Te Source of truth: `PaneMessage` in `lib/src/components/design.tsx`. Visual regression cases: `lib/src/stories/ToolApproval.stories.tsx`. -### Spoken-alarm overlay +### Alarm overlay -A terminal Session with transient speech-delivery state gets a pointer-transparent overlay spanning its whole Lath leaf; browser surfaces never render it. It resolves through the tiling engine's per-leaf overlay slot (`docs/specs/tiling-engine.md`) and **must never intercept pointer/focus routing or change leaf geometry**. +A ringing terminal Session gets an overlay spanning its whole Lath leaf; browser surfaces never render it. It resolves through the tiling engine's per-leaf overlay slot (`docs/specs/tiling-engine.md`) and **must never intercept pointer/focus routing or change leaf geometry**. **Two layers straddling the header's stacking context** (`.lath-leaf-header` is `position: relative; z-index: 20`): -- **Wash + label at `z-index: 19`** — above terminal content, below the header and below the `z-index: 20` pane-corner mouse-override banner, so neither is tinted (rationale). Both states wash, `SPEAKING` at 20% opacity and `SPOKEN` at half that — `SPOKEN` is an unbounded window, so its haze must stay light enough to read terminal text through. **Never use color-alpha utilities here** — their emitted `color-mix()` is unsupported by the standalone Safari 15 / Chrome 105 targets; the solid alarm color lives on a dedicated child whose element opacity supplies those strengths. The label sits `PANE_HEADER_HEIGHT_PX + 4` from the Pane top, centered, in both states. -- **Perimeter ring at `z-index: 25`** — above the header so the treatment reads as one rounded rectangle around the whole Pane, below the `z-index: 30` sashes (rationale). 5px for `SPEAKING`, 3px for `SPOKEN`. +- **Wash + label at `z-index: 19`** — above terminal content, below the header and the `z-index: 20` pane-corner mouse-override banner, so neither is tinted (rationale). **Never use color-alpha utilities here** — their `color-mix()` is unsupported by the standalone Safari 15 / Chrome 105 targets; the solid alarm color lives on a dedicated child whose element opacity supplies those strengths. The label sits `PANE_HEADER_HEIGHT_PX + 4` from the Pane top, centered. +- **Perimeter ring at `z-index: 25`** — above the header so the treatment reads as one rounded rectangle around the whole Pane, below the `z-index: 30` sashes (rationale). -Both layers wear the leaf's own rounding (header radius on top, terminal radius on the bottom). Under `SPEAKING` both pulse when motion is allowed and `cfg.alert.ringingPaused` is not set. Source of truth: `lib/src/components/wall/AlertSpeechIndicator.tsx`, registered as the `terminal` overlay by `lib/src/components/wall/LathHost.tsx`. +Three strengths, by speech state over the latched ring (`docs/specs/alert.md` → Pane Header). `SPOKEN` is unbounded, so its wash stays light enough to read terminal text through: + +| State | Wash | Ring | Label | +|---|---|---|---| +| ringing only | 10% | 3px | none | +| `SPEAKING` | 20% | 5px | `SPEAKING` + speaker icon | +| `SPOKEN` | 10% | 3px | `SPOKEN` + speaker icon | + +Both layers wear the leaf's own rounding (header radius on top, terminal radius on the bottom) and carry the motion `docs/specs/alert.md` → Pane Header specifies. Source of truth: `AlertRingIndicator` in `lib/src/components/wall/AlertRingIndicator.tsx`, registered as the `terminal` overlay by `lib/src/components/wall/LathHost.tsx`. ### Pane header responsive sizing diff --git a/docs/specs/layout.rationale.md b/docs/specs/layout.rationale.md index b82170696..0861a7e36 100644 --- a/docs/specs/layout.rationale.md +++ b/docs/specs/layout.rationale.md @@ -20,7 +20,7 @@ The browser's 94/102 pair is the former 72/80 pair plus the zoom button and its xterm.js paints only its own rendered surface, and integer row fitting leaves a sub-row remainder at the bottom of the pane: a host background differing from the terminal screen shows as a stripe under the last row, and an unclipped host squares off the rounded bottom corners. -## Spoken-alarm overlay +## Alarm overlay **Why the wash sits below the header.** `--color-alarm-vs-terminal` is picked for contrast against the *terminal body*, so it carries no contrast guarantee over the header band. diff --git a/docs/specs/theme.md b/docs/specs/theme.md index e69186548..ae993643e 100644 --- a/docs/specs/theme.md +++ b/docs/specs/theme.md @@ -46,7 +46,7 @@ runtime instead: | `--color-focus-ring` | a chromatic `focusBorder`, else a chromatic active-header background, else the candidate furthest from `--color-app-bg`; "chromatic" is OKLab chroma ≥ `FOCUS_RING_SATURATION_FLOOR` | | `--color-alarm-vs-{header-active,header-inactive,door,terminal}` | plain white or black, by the OKLab lightness of the background the alert treatment sits on (rationale) | -The terminal alarm tint drives the whole-Pane spoken-alarm overlay. +The terminal alarm tint drives the whole-Pane alarm overlay. **Must derive the Door alarm tint from the newly chosen background in the same pass.** Pinned by `lib/src/lib/themes/dynamic-palette.test.ts`. **Must refresh dynamic picks on `body` or `html` class/style changes and repair diff --git a/docs/specs/tiling-engine.md b/docs/specs/tiling-engine.md index c2781fbf3..6ab13471d 100644 --- a/docs/specs/tiling-engine.md +++ b/docs/specs/tiling-engine.md @@ -163,7 +163,7 @@ Source of truth: `lib/src/components/wall/lath-wall-store.ts`; `lib/src/componen **An adapter owns exactly three things**: mapping input into Wall coordinates, applying animator frames to its scene each tick, and hosting pane content. Layout, ops, sash geometry, and animation timelines are core and shared; LathHost is the engine's only non-headless part. - One flat container; one stable `position: absolute` div per leaf, keyed by id, carrying `data-lath-leaf`, moved and resized by inline styles, hosting pane content as ordinary React children. The div is **never re-parented, never reordered, and never unmounted** except on a remove commit, and **leaf divs render in sorted-by-id DOM order, not tree order** — reordering keyed siblings moves DOM nodes, blurring the xterm inside one and reloading a moved `