Skip to content
Merged
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
2 changes: 1 addition & 1 deletion DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ The most distinctive motion in the system. Implemented as `clip-path` reveals, n
- **Reduced-motion:** all of the above are nulled.

#### Marching Ants (Command Mode)
The selection ring around the focused pane in command mode is an SVG with `stroke-dasharray` and an infinite `marching-ants` keyframe that increments `stroke-dashoffset` by `var(--march-offset)`. Color: `var(--color-focus-ring)`. This is the system's only ongoing animation; it is meant to be the visual signature of "you are now in command mode." The ring stays crisp while travelling; motion reads instead from soft directional bands drawn behind each edge, sized by how fast that edge is moving across itself.
The selection ring around the focused pane in command mode is an SVG with `stroke-dasharray` and a `marching-ants` keyframe that increments `stroke-dashoffset` by `var(--march-offset)`. Color: `var(--color-focus-ring)`. It is the visual signature of "you are now in command mode," so it marches in a short burst on entry and on each selection change rather than forever — an idle wall runs no animation (timing in `docs/specs/layout.md` → Selection overlay). The ring stays crisp while travelling; motion reads instead from soft directional bands drawn behind each edge, sized by how fast that edge is moving across itself.

#### Focus Ring Travel & Header Crossfade
When selection moves between panes/doors, the focus ring **glides** to the new target over 220ms (`FOCUS_MOTION_MS`, half the pane-motion duration) on the house curve `cubic-bezier(0.22, 1, 0.36, 1)`, and the source/destination pane headers crossfade their active/inactive palette over the same 220ms (`HEADER_PALETTE_TRANSITION_CLASS` in `design.tsx`), so the two read as one gesture. The ring's rect is a per-frame JS tween (`rect-tween.ts`), not a CSS transition; same-identity re-measures (sash drag, window resize, animator frames) snap 1:1, and a pane↔door move lerps the corner radii so the shape never pops. Reduced motion nulls both: the ring snaps and the header palette swaps instantly.
Expand Down
4 changes: 2 additions & 2 deletions docs/specs/alert.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,7 @@ Where it surfaces is host-specific:

The header shows an alert bell, a fixed-text `TODO` pill when `todo === true`, a hover/focus notification preview when TODO has `notification`, and a dialog opened by right-click or by some left-click actions. Placement, sizing, and width tiers belong to `docs/specs/layout.md`.

Bell visual state is a pure function of public status. **The bell names the command it would act on** ("Alert on all `claude`"), not an abstract toggle — that is the scope of what a click changes.
Bell rotation follows public status; motion follows latch edges. **When a track latches, ring each mounted bell for four 800ms cycles, then hold 45° until the ring clears** (test: `runs a finite ringing burst and then holds the bell at 45 degrees` in `lib/src/components/bell-icon-class.test.ts`; rationale). **A newly mounted ringing bell may replay once without advancing `ringSeq`** (test: `replays the finite burst when a ringing presentation remounts` in `lib/src/components/AlertBell.test.tsx`; rationale). **A newly latched track replays the burst; further reports on that track only enrich its summons.** `AlertState.ringSeq` counts per-Session latches and is compared by `alertStatesEqual` (tests: `counts a second track ringing behind an already-latched one` and `does not count a track that is already ringing` in `lib/src/lib/alert-manager.test.ts`, `replaces the icon when the ring counter advances` in `lib/src/components/AlertBell.test.tsx`; rationale). **Remote Clients have no counter:** `DirectoryEntry.ringing` is an edgeless boolean, so Pocket rings on mount and holds. **The bell names the command it would act on** ("Alert on all `claude`"), not an abstract toggle — that is the scope of what a click changes.

Bell interactions — one transition table, in `dismissOrToggleAlert`:

Expand All @@ -351,7 +351,7 @@ The TODO pill always displays `TODO`; remote notification text belongs in previe

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.

Source of truth: `bellIconClass` in `lib/src/components/bell-icon-class.ts`; `dismissOrToggleAlert` in `lib/src/lib/session-activity-store.ts`; `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`; `dismissOrToggleAlert` in `lib/src/lib/session-activity-store.ts`; `lib/src/components/TodoPillBody.tsx`; `lib/src/components/wall/AlertSpeechIndicator.tsx`.

### Door

Expand Down
10 changes: 9 additions & 1 deletion docs/specs/alert.rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,15 @@

## Pane Header

**Why `cfg.alert.ringingPaused` suppresses the pulse.** It is the Chromatic freeze that pins the bell: an infinite opacity cycle would otherwise snapshot at an arbitrary phase and diff against itself on every run.
**Why the bell rings only four times.** With four focused ringing bells, the former infinite animation added 6.89 MB of embedder memory, 1,127 style recalculations, and 3.99 seconds of renderer CPU over three minutes. Pausing only those animations in the same loaded document reduced that to 0.13 MB, two recalculations, and 0.025 seconds. After bounding the burst, two consecutive three-minute windows each had zero live animations, one recalculation, under 0.40 MB of non-cumulative embedder drift, and at most 0.024 seconds of renderer CPU (measured in Chrome 150, 2026-09). Four cycles preserve the entry cue without leaving a per-Session animation running for the lifetime of an unattended alert.

**Why a counter, not the status.** Bounding the burst turned a continuous cue into an edge-triggered one, and the public status has no such edge: `hasActiveRing` ORs three independently latching tracks, so a second alert behind a latched one leaves `ALERT_RINGING` in place. `notification` is no better — `applyCommandExitRinging` deliberately preserves a richer protocol notification. With both unchanged, `alertStatesEqual` also judged the two states equal and never emitted, so the renderer could not have reacted even had it wanted to. `ringSeq` is the smallest thing that changes exactly once per latch.

**Why a presentation mount may replay.** Minimizing and reattaching move the visible cue between a Pane and a Door. Replaying once makes the cue legible in its new location without carrying the CSS animation clock through Activity state; the finite burst still expires without further input.

**Why latches and not notifications.** Counting every ring rule instead would let a Session bell-ing in a loop emit one host→webview update per PTY chunk, each restarting a 3.2s burst that never finishes — the always-running animation the finite burst exists to remove. A latch advances the counter at most once while that track remains latched; after release, relatching is a fresh summons and may replay. That matches the model `deferOrDeliverNotification` already states: an existing ring is enrichment, not a fresh summons. A timestamp floor would bound notifications too, but it would put the CSS duration in the manager.

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

## Text And Security

Expand Down
4 changes: 2 additions & 2 deletions docs/specs/layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ A fixed-positioned element on top of the Lath host, covering the active element'

- **Exactly one pane or door is active at a time**, drawn by one SVG renderer (`SelectionRing`, `variant: 'ants' | 'solid'`).
- **Passthrough:** `variant='solid'` — a 1px solid SVG stroke, centerline `strokeWidth/2` inside the div edge for panes and doors alike, no glow (rationale).
- **Command:** `variant='ants'` — animated marching-ants border (`cfg.marchingAnts`: 10px segment, 60% dash / 40% gap, 0.4s cycle, 2px stroke), unchanged while the ring travels; the motion smear is a separate layer behind it ([Ring travel](#ring-travel)). The animation pauses while the window is unfocused, and the whole ring drops to `saturate(0.3)` then.
- **Command:** `variant='ants'` — marching-ants border (`cfg.marchingAnts`: 10px segment, 60% dash, four 0.4s cycles, 2px stroke). **Run the burst on command entry or identity change, then hold still** (test: `starts a finite burst on command entry and remounts the outline on a selection change` in `lib/src/components/wall/WorkspaceSelectionOverlay.test.tsx`; rationale). Keep it unchanged during travel and draw the smear separately ([Ring travel](#ring-travel)). **While unfocused, pause it and apply `saturate(0.3)` to the ring.**
- Border radius follows DESIGN.md's Concentric-Corners Rule: the pane ring's radius is the pane radius plus the inflate (`PANE_SELECTION_RING_RADIUS_PX`), with the marching-ants path inset so its stroke centerline sits on the same gutter midline; doors sit at zero offset and keep `0.5rem 0.5rem 0 0`.
- Color is the resolved `--color-focus-ring`, **re-read whenever `document.body`'s class/style changes**, because the dynamic palette publishes it there (`useFocusRingColor`).
- `z-index: 50`, `pointer-events: none`.
Expand All @@ -192,7 +192,7 @@ A fixed-positioned element on top of the Lath host, covering the active element'

The ring's rect (and its `{tl,tr,br,bl,inset}` shape) is driven **per-frame by a JS tween, never a CSS transition**; DESIGN.md's ban on animating layout properties does not reach it (rationale). Motion is `FOCUS_MOTION_MS` (220ms — half `LATH_MOTION_MS`) on the house curve `cubic-bezier(0.22, 1, 0.36, 1)`.

Per-frame writes are **imperative**: `SelectionRing` renders a stable shell once (per variant/color/focus change) and lifts its DOM nodes back to the overlay via refs; the rAF loop writes rect, path `d`, marching-ants dash, and every smear piece's `d`/width/opacity directly, then **re-applies once after any structural render, pre-paint**, so a freshly mounted ring never flashes. **Never reintroduce per-frame React state** — reconciling this subtree every frame competes with the travel for the frame budget (rationale).
Per-frame writes are **imperative**: `SelectionRing` gives the overlay refs to its stable shell; the rAF loop writes rect, path `d`, marching dash, and smear geometry, then **re-applies after structural renders, pre-paint**, so fresh nodes do not flash. **Never reintroduce per-frame React state** — reconciling this subtree competes with travel for the frame budget (rationale).

- **Identity change → tween.** A measurement whose identity (`${selectedType}:${selectedId}`) differs from the one on screen glides from the current interpolated position to the new target, **clock restarted**, so arrow-key spam stays responsive.
- **Same identity → snap 1:1.** A same-identity re-measure with no tween in flight (sash drag, window resize, a settled leaf's store commit) writes the new rect directly, tracking the geometry exactly instead of easing behind it.
Expand Down
2 changes: 2 additions & 0 deletions docs/specs/layout.rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ The passthrough `solid` variant replaced an original `border: 1px solid ${color}

**The inflate arithmetic.** With `SELECTION_RING_INFLATE_PX` at 4, the 1px passthrough border spans [3px, 4px] from the pane edge — dead centre of the 7px gutter, on whole pixels because the gutter is odd. That is the whole reason `PANE_GUTTER_PX` is odd.

**Why marching is burst-bound.** An infinite SVG stroke animation kept Chrome's renderer active at 60 style recalculations per second while Dormouse was otherwise idle. Measured in Chrome for Testing 150 (2026-09): five focused minutes added 3.77 MB of reclaimable embedder heap and used 24.33 seconds of renderer CPU; pausing only that animation held embedder heap flat (-29 KB) and used 0.017 seconds across a three-minute control. Four cycles preserve the mode/selection cue without leaving a standing allocator after interaction stops.

## Ring travel

**Why the JS tween is not what DESIGN.md bans.** That rule bans CSS *transitions* on layout properties, which the compositor cannot run off the main thread; the overlay writes true interpolated values each rAF frame, inside the same pointer-events-none carve-out the Lath animator holds.
Expand Down
2 changes: 1 addition & 1 deletion docs/specs/vscode.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ The webview is the shared `lib/` frontend, unmodified for this host (`docs/specs
- **Alert state is global.** One module-level `AlertManager` in `message-router.ts` is shared across all routers, survives router disposal, and is fed by PTY data regardless of webview visibility.
- **WATCHING rules are host-authoritative.** The first webview after extension-host startup seeds the shared host rule set and **no later webview may replace it**.
- **Never let a resuming router steal another webview's PTYs.** Each router tracks its PTYs in `ownedPtyIds`; a module-level `globalOwnedPtyIds` set enforces it.
- **Every save path must merge current alert states.** The frontend periodic save (`onSaveState`) and the backend deactivate refresh (`refreshSavedSessionStateFromPtys`) both call `mergeAlertStates` — missing it reverts alert state on restore.
- **Every save path must merge current alert states through the shared persistence projection.** The frontend periodic save (`onSaveState`) and the backend deactivate refresh (`refreshSavedSessionStateFromPtys`) both narrow alerts with `toPersistedAlertState`; missing the merge reverts alert state on restore, while passing live state through persists transient fields.
- **retainContextWhenHidden.** Set on both `WebviewPanel` and `WebviewView` so xterm.js DOM, scrollback, and PTY subscriptions survive panel hide/show without a resume.
- **Two save sources must produce consistent state**: the frontend's periodic `dormouse:saveState` and the backend's deactivate flush-then-refresh.
- **Every host → webview send carries the message token**, and **never add a `message` listener that skips `isHostMessage`**.
Expand Down
2 changes: 2 additions & 0 deletions lib/src/cfg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export const cfg = {
dashFraction: 0.6,
/** Seconds for one full dash-gap cycle. */
cycleDuration: 0.4,
/** Cycles to run when command mode starts or the active selection changes. */
cyclesPerSelection: 4,
/** Stroke width in px. */
strokeWidth: 2,
/** When true, animation is frozen at T=0 (for deterministic Chromatic snapshots). */
Expand Down
57 changes: 57 additions & 0 deletions lib/src/components/AlertBell.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/** @vitest-environment jsdom */
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, expect, it } from 'vitest';
import { AlertBell } from './AlertBell';

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(async () => {
await act(async () => root.unmount());
container.remove();
});

const bell = () => container.querySelector('svg');

// `docs/specs/alert.md` -> Pane Header. The class assertion is the premise: it
// is identical across rings, so only the remount can restart the burst.
it('replaces the icon when the ring counter advances', async () => {
await act(async () => root.render(<AlertBell status="ALERT_RINGING" ringSeq={1} size={14} />));
const first = bell();
expect(first?.getAttribute('class')).toContain('animate-bell-ring');

await act(async () => root.render(<AlertBell status="ALERT_RINGING" ringSeq={2} size={14} />));
const second = bell();
expect(second).not.toBe(first);
expect(second?.getAttribute('class')).toBe(first?.getAttribute('class'));
});

// Re-renders that are not a new latch must leave the animation alone, or a burst
// restarts on every unrelated store commit.
it('keeps the icon across a re-render at the same ring counter', async () => {
await act(async () => root.render(<AlertBell status="ALERT_RINGING" ringSeq={1} size={14} />));
const first = bell();

await act(async () => root.render(<AlertBell status="ALERT_RINGING" ringSeq={1} size={14} className="shrink-0" />));
expect(bell()).toBe(first);
});

it('replays the finite burst when a ringing presentation remounts', async () => {
await act(async () => root.render(<AlertBell status="ALERT_RINGING" ringSeq={1} size={14} />));
const first = bell();

await act(async () => root.render(null));
await act(async () => root.render(<AlertBell status="ALERT_RINGING" ringSeq={1} size={14} />));

expect(bell()).not.toBe(first);
expect(bell()?.getAttribute('class')).toContain('animate-bell-ring');
});
34 changes: 34 additions & 0 deletions lib/src/components/AlertBell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { BellIcon } from '@phosphor-icons/react';
import { clsx } from 'clsx';
import type { SessionStatus } from '../lib/terminal-registry';
import { bellIconClass } from './bell-icon-class';

/**
* The status bell for one Session — the only place a `BellIcon` is drawn.
*
* Every ring must replay the finite ringing burst, and only a remount can start
* one, because the className is identical across two rings
* (`docs/specs/alert.md` -> Pane Header). Keying here rather than at each call
* site is what keeps that from being a rule call sites have to remember.
*
* One element, not a branch per status: two `BellIcon`s in the same position
* would remount on every crossing into `WATCHING_DISABLED` — i.e. every command
* boundary — which is churn with no burst to show for it.
*/
export function AlertBell({ status, ringSeq, size, className }: {
status: SessionStatus;
/** `ActivityState.ringSeq` — read only for change, never magnitude. */
ringSeq: number;
size: number;
className?: string;
}) {
const watching = status !== 'WATCHING_DISABLED';
return (
<BellIcon
key={ringSeq}
size={size}
weight={watching ? 'fill' : 'regular'}
className={watching ? clsx(bellIconClass(status), className) : className}
/>
);
}
2 changes: 2 additions & 0 deletions lib/src/components/Baseboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ export function Baseboard({ items, onReattach, notice, onDoorDragStart }: Basebo
key={item.id}
title={title}
status={activity.status}
ringSeq={activity.ringSeq}
todo={activity.todo}
speechState={speechStates.get(item.id)}
/>
Expand Down Expand Up @@ -250,6 +251,7 @@ export function Baseboard({ items, onReattach, notice, onDoorDragStart }: Basebo
doorId={item.id}
title={title}
status={activity.status}
ringSeq={activity.ringSeq}
todo={activity.todo}
speechState={speechStates.get(item.id)}
onClick={() => onReattach(item)}
Expand Down
Loading