Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:** one 2px inset overlay (`--color-alarm-vs-door`) draws the edge for both the unlabelled ring, where it flashes once on arrival, and `SPOKEN`, which persists until the ring is attended and adds a speaker icon *beside* the TODO pill and bell instead of evicting them. `SPEAKING` instead inverts and pulses the whole Door and takes the badge slot for its speaker-plus-label, for one utterance. Both speech states carry a speaker icon (shape, not color); all three name the state in the accessible name. The row inventory is `docs/specs/layout.md` → Alarm overlay.
- **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
Expand Down
5 changes: 3 additions & 2 deletions docs/specs/alert.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**, 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.


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`; `alertRingRow` and `alertRingBurstProps` in `lib/src/components/alert-ring.ts`.

### Door

Expand All @@ -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 ring `spoken` uses, unlabelled, named `needs attention`
- 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

Expand Down
2 changes: 2 additions & 0 deletions docs/specs/alert.rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 13 additions & 5 deletions docs/specs/layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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 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 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. `SPOKEN` is unbounded, so its wash stays light enough to read text through:

| State | Wash | Ring | Label |
|---|---|---|---|
| ringing | 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). **Only the perimeter ring animates**, compositing one layer rather than two; `docs/specs/alert.md` → Pane Header owns the motion. 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

Expand Down
2 changes: 1 addition & 1 deletion docs/specs/layout.rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/specs/theme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/specs/tiling-engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<iframe>`.
- Each leaf div is a 30px header slot over a filling body, plus an optional whole-leaf **overlay** slot for chrome spanning header *and* body; header slot and zoom inset both derive from `PANE_HEADER_HEIGHT_PX` in `lib/src/components/design.tsx`. **All three slots resolve from `leafMeta.component` / `.tabComponent` through one registry** — body `terminal` → TerminalPanel and `browser` → BrowserPanel, tab `terminal` / `surface`, overlay `terminal` → the spoken-alarm indicator and the terminal context — never a surface-kind branch beside it; `componentsOverride` is the jsdom test seam for all three. **The positioned wrapper carries geometry only** — header, body, and overlay live in a memoized inner unit keyed on `{ id, meta, parked, resolved components }`, so a geometry-only frame never re-renders the content.
- Each leaf div is a 30px header slot over a filling body, plus an optional whole-leaf **overlay** slot for chrome spanning header *and* body; header slot and zoom inset both derive from `PANE_HEADER_HEIGHT_PX` in `lib/src/components/design.tsx`. **All three slots resolve from `leafMeta.component` / `.tabComponent` through one registry** — body `terminal` → TerminalPanel and `browser` → BrowserPanel, tab `terminal` / `surface`, overlay `terminal` → the alarm indicator and the terminal context — never a surface-kind branch beside it; `componentsOverride` is the jsdom test seam for all three. **The positioned wrapper carries geometry only** — header, body, and overlay live in a memoized inner unit keyed on `{ id, meta, parked, resolved components }`, so a geometry-only frame never re-renders the content.
- Sashes render from core `sashes()` geometry as sibling divs (hit area widened to 8px, cursor per axis); a drag streams a core `resize` preview from the drag-start tree with the cumulative delta and proposes one commit on pointerup (`onCommitResize`); Escape cancels. **Geometry is reported through `store.setLayoutGeometry` from inside the measuring layout effect, never a passive effect over the rendered size** (rationale); the store's zero-area rejection is the backstop.
- Zoom retargets only the chosen leaf to the wall rect inset by `LATH_ZOOM_MARGIN` (half a pane header) and elevates it above tiled/dying panes and sashes, applying the blurred `LATH_ZOOM_SHADOW` while elevated. Unzoom keeps both until the return frame settles.
- **The binding never calls `.focus()` and emits no activation events.** Gestures surface as proposals (`onCommitResize`, `onLeafFocused`, the drag callbacks) that the Wall commits.
Expand Down
3 changes: 2 additions & 1 deletion lib/src/components/AlertBell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { BellIcon } from '@phosphor-icons/react';
import { clsx } from 'clsx';
import type { SessionStatus } from '../lib/terminal-registry';
import { bellIconClass } from './bell-icon-class';
import { animationClockStyle } from './alert-ring';

/**
* The status bell for one Session — the only place a `BellIcon` is drawn.
Expand All @@ -28,7 +29,7 @@ export function AlertBell({ status, ringSeq, ringStartedAt, size, className }: {
const watching = status !== 'WATCHING_DISABLED';
const animation = useMemo(() => ringStartedAt === undefined ? undefined
: ringStartedAt === null ? { animation: 'none' }
: { animationDelay: `${-Math.max(0, Date.now() - ringStartedAt)}ms` }, [ringStartedAt]);
: animationClockStyle(ringStartedAt), [ringStartedAt]);
return (
<BellIcon
key={ringSeq}
Expand Down
1 change: 1 addition & 0 deletions lib/src/components/Baseboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@ export function Baseboard({ items, onReattach, notice, onDoorDragStart }: Basebo
ringSeq: activity.ringSeq,
todo: activity.todo,
speechState: speechStates.get(item.id),
episode: activity.episode ?? null,
noteCount: notepadAvailable ? (notepadNotes.get(item.id)?.length ?? 0) : 0,
};
};
Expand Down
Loading
Loading