From c1ab0469689bc664d112ab5c05a495a9300b9d55 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 20 Sep 2026 00:16:46 -0700 Subject: [PATCH 1/2] docs(layout): correct the tiling and Workspace specs against the code Audit corrections across glossary / layout / tiling-engine, funded by condensing sections that restated their neighbours. Corrections: - restore reinserts into an empty tree; only add/split/insert refuse one, and replace/swap write through replaceAtPath rather than normalize - tool Surfaces park on minimize, keep their session while Doored, and resolve body/header/overlay slots through the same registry - persistableLeafMeta strips a Tool's derived browser fields and demotes a pending Tool to a plain terminal, so a restart reloads it as its terminal - the cold restore path carries the same leaf-set gate as resume, and synthesizes a single-row layout when a visible pane is a Tool - restore tokens carry leafId, write the sibling fields on every non-root removal, and consult them only for a multi-leaf sibling - the marching ants are re-dashed every frame and also pause under cfg.marchingAnts.paused; only the smear's non-interference is absolute - a key inside the terminal context never reaches dual-tap - a hidden Wall gates every listener that dispatches, forwards, or preventDefaults window input, custom events included - unzoom also fires on selecting a Workspace tab - rate callers read slope() off cubicBezier/LATH_EASING, not the animator - corrected Source-of-truth pointers for zoom, window focus, and persistence; bare test filenames expanded to repo paths Condensed: the glossary's rollout note and host-count bullets; layout's corner cases (only #2 and #6 are cited elsewhere), its restatements of the tiling engine's DnD and animation models, and the Workspace lifecycle prose. Budgets: glossary 3000 -> 2950, layout 10000 -> 9950, tiling-engine 4500 -> 4650. Co-Authored-By: Claude Fable 5.1 --- docs/specs/glossary.md | 9 +--- docs/specs/glossary.rationale.md | 2 +- docs/specs/layout.md | 77 +++++++++++++++++--------------- docs/specs/tiling-engine.md | 26 +++++------ lib/src/lib/lath/persistence.ts | 5 ++- scripts/spec-word-budgets.json | 6 +-- 6 files changed, 61 insertions(+), 64 deletions(-) diff --git a/docs/specs/glossary.md b/docs/specs/glossary.md index 13b57f3c7..50dcb5465 100644 --- a/docs/specs/glossary.md +++ b/docs/specs/glossary.md @@ -70,10 +70,7 @@ Workspace and Window are containers, not Session layers — they group Surfaces | **Window** | One or more Workspaces; the OS frame (a standalone Tauri window) or the host frame (a VS Code window). A host may hold several, and a Workspace may move between them. Its **Tauri label is its persistence identity** — one snapshot per label (`docs/specs/standalone.md` → Windows). | host (Tauri / VS Code) | | **Workspace** | "A window's worth of panes": a `WorkspaceId`, a user-facing `name`, its Panes and Surfaces, and the layout arranging them (Lath snapshot + doors). Exactly one **Wall** renders one Workspace. | `lib/src/lib/workspace-store.ts` (the model), `lib/src/components/Wall.tsx` at render time; persisted per `docs/specs/transport.md` | -How many Workspaces a Window shows at once is host-specific: - -- **Standalone** mounts every Workspace's Wall at once and shows one, switching between them (`docs/specs/layout.md` → Workspaces); a Window may be one of several. -- **VS Code** maps one Workspace to one webview, several visible at once: the sidebar/panel `WebviewView` is the default Workspace, each `dormouse.open` editor-tab `WebviewPanel` an independent one owning its Sessions' PTYs and browser Surfaces (`docs/specs/vscode.md`). +How many Workspaces a Window shows at once is host-specific: standalone mounts every Wall and shows one (`docs/specs/layout.md` → Workspaces), VS Code maps each Workspace to its own webview (`docs/specs/vscode.md`). ### Wall chrome @@ -89,10 +86,6 @@ How many Workspaces a Window shows at once is host-specific: A Workspace's **union status** is its display projection of member Surfaces' Activity; `docs/specs/alert.md` → Workspace union owns its fields and rules. -### Implementation status - -The Pane / Surface model, surface kinds, the Workspace model, per-Workspace persistence, several Windows each holding several Workspaces, and the `dor workspace` verbs over them are all live (`docs/specs/layout.md` → Workspaces); this glossary tracks no rollout. - ## Roles Remote control has exactly three roles. `docs/specs/remote-security-model.md` owns the trust between them; these are the names. diff --git a/docs/specs/glossary.rationale.md b/docs/specs/glossary.rationale.md index c6fa2d6b2..82f2960c9 100644 --- a/docs/specs/glossary.rationale.md +++ b/docs/specs/glossary.rationale.md @@ -6,7 +6,7 @@ **Why `dor` addresses content by Surface ref, not Pane ref.** Once an in-pane surface strip puts several Surfaces in one Pane, every `read` / `send` / `await` / `kill` spelled against a Pane becomes ambiguous, while the layout-only commands still mean one thing — so Pane refs are left unspent for those. -**Why every row carries both capability flags.** `kind` is an enum, so a caller that branches on `kind === 'terminal'` silently stops matching the day a kind carrying both capabilities ships — the staged `tool` (`docs/specs/dor-tool.md`) is that kind. `has_terminal` / `has_browser` express the same fact in a form that keeps matching, so a script written against today's two kinds still selects correctly against three; emitting them unconditionally, rather than only where they differ from the kind, is what makes that free to rely on. +**Why every row carries both capability flags.** `kind` is an enum, so a caller that branches on `kind === 'terminal'` silently stops matching the day a kind carrying both capabilities ships — `tool` (`docs/specs/dor-tool.md`) shipped as exactly that kind. `has_terminal` / `has_browser` express the same fact in a form that keeps matching, so a script written against today's two kinds still selects correctly against three; emitting them unconditionally, rather than only where they differ from the kind, is what makes that free to rely on. ## Invariants diff --git a/docs/specs/layout.md b/docs/specs/layout.md index a82c3e2c1..7c23f06f2 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -10,7 +10,7 @@ ## Conceptual model -A Wall renders one Workspace's Surfaces as Panes in Content or Doors on the Baseboard. Pane↔Door preserves the Surface; a Doored browser Surface keeps its backing session while releasing its viewer resources ([Minimize and reattach](#minimize-and-reattach)). Standalone mounts one Wall per Workspace and switches between them ([Workspaces](#workspaces)). VS Code maps each Workspace to a webview (`docs/specs/vscode.md`). +A Wall renders one Workspace's Surfaces as Panes in Content or Doors on the Baseboard. Pane↔Door preserves the Surface; a Doored browser or Tool Surface keeps its backing session while releasing its viewer resources ([Minimize and reattach](#minimize-and-reattach)). Standalone mounts one Wall per Workspace and switches between them ([Workspaces](#workspaces)). VS Code maps each Workspace to a webview (`docs/specs/vscode.md`). ## Shell layout @@ -42,7 +42,7 @@ Each pane is one **leaf** in Lath's split tree — a stable, absolutely-position Panes are separated by a 7px gap (`PANE_GUTTER_PX`), odd so the 1px selection ring centers in it on whole pixels ([Selection overlay](#selection-overlay)). -**Center drop = swap, edge drop = split.** Dragging a pane onto another's *center* swaps their Surfaces exactly as `Cmd/Ctrl+Arrow` does ([Spatial navigation](#spatial-navigation)); onto an *edge* band splits beside that leaf, or beside an ancestor column/row chosen by scroll-wheel depth. `docs/specs/tiling-engine.md` → "Hierarchical drag and drop" owns the model; the Wall owns only the op commit + selection policy. **A baseboard drop is a no-op when `showBaseboard={false}`** — there is nowhere to minimize into. Source of truth: `onProposeMove` / `onProposeMinimize` / `onExternalDrop` in `lib/src/components/Wall.tsx`. +**A pane drag's depth model — center swap, edge split, ancestor levels by scroll wheel — belongs to `docs/specs/tiling-engine.md` → "Hierarchical drag and drop"**; the Wall owns only the op commit and the selection policy after it, a center drop landing exactly where `Cmd/Ctrl+Arrow` would ([Spatial navigation](#spatial-navigation)). **A baseboard drop is a no-op when `showBaseboard={false}`** — there is nowhere to minimize into. Source of truth: `onProposeMove` / `onProposeMinimize` / `onExternalDrop` in `lib/src/components/Wall.tsx`. ### Pane header @@ -159,7 +159,7 @@ Doors are measured in a hidden off-screen container first, then fitted: - **Subtract the measured right cluster and its gap before fitting anything** — that space is never available to doors. Measure only its always-present part (notice + the three settings controls): **never the overflow arrow**, whose presence is an *output* of the fit. - Add doors until no more fit, reserving room for a `N more →` button whenever items remain after the current one. **At least one door is always shown**, even if it overflows. - If scrolled, show `← N more` on the left and/or `N more →` on the right. Overflow counts are assumed single-digit (the hidden measurement button is `9 more`). -- Clicking an overflow arrow reveals one door in that direction; a longer title may push more doors off the opposite side. **Must reveal the selected Door when selection or membership changes**, without overriding manual overflow scrolling (`Baseboard.test.tsx`). +- Clicking an overflow arrow reveals one door in that direction; a longer title may push more doors off the opposite side. **Must reveal the selected Door when selection or membership changes**, without overriding manual overflow scrolling (`lib/src/components/Baseboard.test.tsx`). - Extreme case — one door with a very long title and more doors on both sides: show both arrows with counts and as much title as fits, ellipsis for the rest. Source of truth: `lib/src/components/Baseboard.tsx`, `lib/src/components/Door.tsx`. @@ -172,7 +172,7 @@ Source of truth: `lib/src/components/Baseboard.tsx`, `lib/src/components/Door.ts - **Must use active and inactive pane-header foreground/background pairs** on the corresponding tabs, over the app background. **Must join the active tab directly to a full-width vertical gradient**, active-header background at the top to app background at the bottom. Reserve one `PANE_GUTTER_PX` band for the gradient above the Wall's normal top gutter, keeping it clear of the focus ring. **Must fade inactive tabs into that same app background below their label.** - **Must show `×` only on the active tab**, outside rename. Middle-click may close an inactive tab. Reveal the active tab after its width changes on activation. - **Must reuse `HEADER_PALETTE_TRANSITION_CLASS` for tab palette tweening and reduced motion.** -- **Must activate inactive tabs in command mode on click or `Enter`; either gesture on the active tab renames without changing mode.** `Enter` keeps the selection on the activated tab. Pinned by `WorkspaceWindow.test.tsx`. +- **Must activate inactive tabs in command mode on click or `Enter`; either gesture on the active tab renames without changing mode.** `Enter` keeps the selection on the activated tab. Pinned by `lib/src/components/WorkspaceWindow.test.tsx`. - **Must separate highlighting from activation.** Only `Enter` or a click activates a highlighted tab; `+` creates a Workspace and enters its pane after mount. Deactivation restores a Wall's chrome selection to its last live pane. External removal of the highlighted Workspace selects a live pane. - **Must reveal a Workspace in command mode before a user close or its confirmation.** `x` on a highlighted Workspace always confirms, including untouched Workspaces; `+` is inert. Successful user closure selects the next Workspace tab in command mode (previous at the end). Silent command closures remain focus-neutral. Pinned by `reveals and confirms x on a highlighted workspace, then selects the next tab for repeated deletion` in `lib/src/components/WorkspaceWindow.test.tsx`. @@ -193,13 +193,13 @@ Source of truth: `createWorkspaceMotion` in `lib/src/components/workspace-motion Each Wall renders one Workspace's Content and Baseboard (doors). Standalone mounts one Wall **per Workspace**; VS Code and the website playground mount a bare Wall with no Workspace id, which behaves exactly as a single-Workspace Window (VS Code's per-webview mapping is `docs/specs/vscode.md`). - **Must mount every Workspace's Wall in one grid cell**, inactive Walls `inert`, then `visibility:hidden` after their fade and never `display:none` (rationale). -- **Must preserve mounted leaves across switches**: no re-seed, no re-parent, no leaf unmount, and no `resumeTerminal` / `restoreTerminal`; the only mount work is the terminal reattach below, which replays nothing, so I8 holds by construction (`WorkspaceWindow.test.tsx`). -- **A hidden Wall's terminals hold no element and no GL context**: completion of the outgoing fade runs `unmountElement` on every terminal pane, exactly as minimize does ([Renderer](#renderer)); activation runs `mountElement` and fits through the [Animations](#animations) gate, so an unchanged grid sends no PTY resize (`TerminalPane.test.tsx`). Browser Surfaces keep their live documents (rationale). -- **A hidden Wall consumes no window input**: every listener it keeps is gated on `active`, so nothing it hears is dispatched, forwarded, or `preventDefault`ed. **Only the active Wall renders the modal hosts and the overlays that trap keys** — the kill confirmation, the refused-archive prompt, a terminal's selection popup (rationale): a staged prompt survives the switch and is answered only where the user can see it. +- **Must preserve mounted leaves across switches**: no re-seed, no re-parent, no leaf unmount, and no `resumeTerminal` / `restoreTerminal`; the only mount work is the terminal reattach below, which replays nothing, so I8 holds by construction (`lib/src/components/WorkspaceWindow.test.tsx`). +- **A hidden Wall's terminals hold no element and no GL context**: completion of the outgoing fade runs `unmountElement` on every terminal pane, exactly as minimize does ([Renderer](#renderer)); activation runs `mountElement` and fits through the [Animations](#animations) gate, so an unchanged grid sends no PTY resize (`lib/src/components/TerminalPane.test.tsx`). Browser Surfaces keep their live documents (rationale). +- **A hidden Wall consumes no window input**: every listener that dispatches, forwards, or `preventDefault`s window input is gated on `active`, keyboard and custom events alike, so a hidden Wall neither mounts chrome nor refits a detached element in answer to one. **Only the active Wall renders the modal hosts and the overlays that trap keys** — the kill confirmation, the refused-archive prompt, a terminal's selection popup (rationale): a staged prompt survives the switch and is answered only where the user can see it. - **Exactly one Wall answers a `dor` request**, chosen by `docs/specs/dor-cli.md` → "Handle Model". Every Wall registers a handle, a bare one under `DEFAULT_WORKSPACE_ID`, so the router always finds one. - **Never unmount a Wall before its Surfaces are disposed** — `closeAll` waits for the kill fade to commit, bounded by the engine's exit duration, since unmounting mid-fade would leave `Orphaned` Registry entries (`docs/specs/glossary.md` → "Invariants" I4). **The deadline refuses rather than reporting clean**, and the walk re-reads membership until nothing is left, so a Surface born behind it is closed too. - **A closing Workspace takes no new Surfaces**: while `closeAll` walks, this Wall answers every Surface-creating `dor` verb with an error (`docs/specs/dor-cli.md` → "Handle Model"). -- **Must reject duplicate Workspace IDs before mutating the model** (`workspace-store.test.ts`). +- **Must reject duplicate Workspace IDs before mutating the model** (`lib/src/lib/workspace-store.test.ts`). - **Must retain mode and selection across switches unless the [activation gesture](#workspace-tabs) changes them.** Deactivation blurs the pane; activation focuses it one frame later. **A Workspace may leave the Window and arrive in another one** — torn out into @@ -211,11 +211,18 @@ not a create: a Workspace that arrives mounts from the record it brought. plain iframe or serving iframe Tool's document cannot leave its webview, so it reopens at its saved URL, and a Workspace holding one — Doored ones included — asks with the Close's typed confirmation before it leaves; agent-browser Surfaces reconnect and ask -nothing (`iframeSurfaceIds` on the Wall handle; `workspace-drag.test.ts`). +nothing (`iframeSurfaceIds` on the Wall handle; `standalone/src/workspace-drag.test.ts`). **Must show drag refusals over Window content in a dialog** until dismissal, retry, or Workspace departure. Source of truth: `onDropOnOtherWindow` in `standalone/src/workspace-drag.ts`; `lib/src/components/WorkspaceStrip.test.tsx`. -**Create** adds a Workspace named `Workspace N`, makes it active, and gives its Wall no restored record, so Lath's fresh branch spawns one default-shell pane. **Close** confirms first when the Workspace holds touched Surfaces or running work, with a kill-confirm letter over the Window's content area, then routes every member Surface through the closure coordinator; **Must atomically replace the last closed Workspace with a fresh Workspace and select its tab**, after disposing the old Surfaces (`WorkspaceWindow.test.tsx`). **Must serialize closes across the Window.** **A Workspace whose Wall has not registered is refused** (`workspace '' is still mounting`, one wording for every caller), never closed past — the Wall walks the member Surfaces, so dropping it would leave its Sessions running unheld (`docs/specs/glossary.md` → "Invariants" I4); **a gesture waits out the registration gap first**, as `dor workspace close` does, so `×` or `&` right after a create closes rather than silently doing nothing. **Rename** edits the Workspace `name` only — no Surface title, and not the per-pane inline rename. **Reorder** moves a tab in the strip and renumbers `workspace:` refs with it only where they are positional (`docs/specs/dor-cli.md` → "Handle Model"); **a press inside the open rename editor never starts a reorder**. **Must drop the closing Workspace’s rename editor and pending confirmation, and no other’s** (`releases the rename lease when the tab being renamed is middle-clicked closed` in `lib/src/components/WorkspaceStrip.test.tsx`; `preserves another Workspace’s rename and close confirmation when closing a sibling` in `lib/src/components/wall/workspace-lifecycle.test.ts`). **Every Workspace verb runs outside the strip**, which renders the rename editor and confirmation from a store, so a tab gesture and a command-mode key take one path. +- **Create** adds a Workspace named `Workspace N`, makes it active, and gives its Wall no restored record, so Lath's fresh branch spawns one default-shell pane. +- **Close** confirms first when the Workspace holds touched Surfaces or running work, with a kill-confirm letter over the Window's content area, then routes every member Surface through the closure coordinator. **Must atomically replace the last closed Workspace with a fresh one and select its tab**, after disposing the old Surfaces (`lib/src/components/WorkspaceWindow.test.tsx`). **Must serialize closes across the Window.** +- **A refused close reveals its Workspace only in `prompt` mode**, activating it so the prompt behind the refusal is on screen rather than inside a hidden Wall; a `silent` close (`dor workspace close`) has no prompt to show and leaves the user where they were (`docs/specs/notepad.md` → "Closure"). +- **A Workspace whose Wall has not registered is refused** (`workspace '' is still mounting`, one wording for every caller), never closed past — the Wall walks the member Surfaces, so dropping it would leave its Sessions running unheld (`docs/specs/glossary.md` → "Invariants" I4). **A gesture waits out the registration gap first**, as `dor workspace close` does, so `×` or `&` right after a create closes rather than silently doing nothing. +- **Rename** edits the Workspace `name` only — no Surface title, and not the per-pane inline rename. +- **Reorder** moves a tab in the strip and renumbers `workspace:` refs with it only where they are positional (`docs/specs/dor-cli.md` → "Handle Model"); **a press inside the open rename editor never starts a reorder**. +- **Must drop the closing Workspace’s rename editor and pending confirmation, and no other’s** (`releases the rename lease when the tab being renamed is middle-clicked closed` in `lib/src/components/WorkspaceStrip.test.tsx`; `preserves another Workspace’s rename and close confirmation when closing a sibling` in `lib/src/components/wall/workspace-lifecycle.test.ts`). +- **Every Workspace verb runs outside the strip**, which renders the rename editor and confirmation from a store, so a tab gesture and a command-mode key take one path. **Must use `WorkspaceKillConfirm` for Workspace close, the iframe move gate, and host termination confirmations**, titled “Confirm kill workspace” except the @@ -258,14 +265,14 @@ Wall starts in `command` mode. Embedders may pass `initialMode="passthrough"` wh - Detected in a capture-phase `keydown` listener on `e.key === 'Meta'` (or `'Shift'`) plus `e.location`, so it fires even while xterm holds DOM focus. **Anything but `location === 1` counts as the right-hand key.** - **The Meta and Shift tracks are independent** — Left Cmd then Right Shift does not trigger — and **both are always live** (rationale). -- **A bare Meta/Shift press is always consumed by this detector**, so no later handler mistakes it for a command key. +- **A bare Meta/Shift press outside the terminal context is always consumed by this detector**, so no later handler mistakes it for a command key; a key targeted inside the context never reaches it ([Keyboard shortcuts](#keyboard-shortcuts-command-mode)). - A zoomed focused pane starts unzoom immediately when keyboard focus returns to command mode. ## Keyboard shortcuts (command mode) `docs/specs/shortcuts.md` tables every binding; this section owns the dispatch behavior behind it. -All keys are handled in one capture-phase `keydown` listener on `window` (`use-wall-keyboard.ts`), which delegates in a fixed order to the modules in `lib/src/components/wall/keyboard/`: *(an inactive Workspace or an answered key stops here)* → dual-tap → editable-field clipboard → mouse-selection keys → *(passthrough stops here)* → *(a rename or the chrome lease stops here)* → kill confirmation → *(an open dialog stops here)* → Workspace shortcuts → pane shortcuts → pane navigation. **Must let one Wall answer each key**, even a key that activates another Workspace (`answers a key from one Wall even when that key activates another` in `lib/src/components/WorkspaceWindow.test.tsx`). **Must prevent default and stop propagation for handled command keys.** Bare Meta/Shift presses stop only internal dispatch; the detector leaves their DOM event untouched. +All keys are handled in one capture-phase `keydown` listener on `window` (`use-wall-keyboard.ts`), which delegates in a fixed order to the modules in `lib/src/components/wall/keyboard/`: *(an inactive Workspace or an answered key stops here)* → dual-tap → editable-field clipboard → mouse-selection keys → *(passthrough stops here)* → *(a rename or the chrome lease stops here)* → kill confirmation → *(an open dialog stops here)* → Workspace shortcuts → pane shortcuts → pane navigation. **A key whose target sits inside `[data-terminal-context]` leaves that chain before dual-tap**: it reaches editable-field clipboard, then mouse-selection keys against the focused helper terminal, and stops — so no Wall gesture, the mode-exit dual-tap included, fires from inside an open context. **Must let one Wall answer each key**, even a key that activates another Workspace (`answers a key from one Wall even when that key activates another` in `lib/src/components/WorkspaceWindow.test.tsx`). **Must prevent default and stop propagation for handled command keys.** Bare Meta/Shift presses stop only internal dispatch; the detector leaves their DOM event untouched. That order is load-bearing twice: a rename input suppresses the pane shortcuts but **not** the mode-exit gesture or the field's own clipboard chords; and a staged kill confirmation hijacks each key reaching it before the dialog gate, so the confirm letter works even though the modal is open. @@ -301,8 +308,8 @@ 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'` — marching-ants border (`cfg.marchingAnts`: 10px segment, 60% dash, 0.4s cycle, 2px stroke). **March for as long as command mode lasts** (test: `marches for as long as command mode lasts, across selection changes` 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.** -- **Never pause the ants in a focused window except during Workspace title editing**, resuming when editing ends without changing mode, **or under reduced motion**, which holds a still dashed ring. Pinned by `pauses while a workspace is renamed, then resumes marching` and `holds the ants still under reduced motion` in `lib/src/components/wall/WorkspaceSelectionOverlay.test.tsx`. +- **Command:** `variant='ants'` — marching-ants border (`cfg.marchingAnts`: 10px segment, 60% dash, 0.4s cycle, 2px stroke). **March for as long as command mode lasts** (test: `marches for as long as command mode lasts, across selection changes` in `lib/src/components/wall/WorkspaceSelectionOverlay.test.tsx`; rationale). **Never restart or retime the march for travel** — only the dash resizes, refitted to the moving perimeter so segments stay even — and draw the smear separately ([Ring travel](#ring-travel)). **While unfocused, pause it and apply `saturate(0.3)` to the ring.** +- **Never pause the ants in a focused window except during Workspace title editing**, resuming when editing ends without changing mode, **under reduced motion**, which holds a still dashed ring, **or under `cfg.marchingAnts.paused`** (Chromatic sets it in `lib/.storybook/preview.ts`). Pinned by `pauses while a workspace is renamed, then resumes marching` and `holds the ants still under reduced motion` in `lib/src/components/wall/WorkspaceSelectionOverlay.test.tsx`. - 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: SELECTION_RING_Z_INDEX` (50), `pointer-events: none`. Under `WorkspaceWindow` it renders into `document.body`, outside the Workspace's transform and stacking context. @@ -331,8 +338,8 @@ Each travelling edge trails a soft band sized by its own motion. A line smears o - **Velocity is analytic**: `sampleRingVelocity` differentiates the tween (`E'` from `LATH_EASING.slope`), so the smear peaks on the opening frame and needs no smoothing. **Never finite-difference rendered positions** (rationale). - **Never divide alpha by the widening factor** — extent and intensity are independent knobs, and `smearFullSpeed` alone shapes a travel (rationale). - **Never collapse the four edge speeds to one horizontal and one vertical**, e.g. from the ring *centre's* velocity (rationale). -- **Two layers**, because one closed path cannot carry four widths (rationale): the smear is a sibling `` of eight pieces drawn underneath, and **the ring (``) is never transformed, re-dashed, or re-alpha'd.** -- **Eight pieces: four edges plus four corners**, every one cut from ONE shared point set (`ringPoints`) that `roundedRectPath` also walks, so the smear provably tiles the ring — pinned by `ring-geometry.test.ts`. A corner reaches two widths at once through a `scale` transform that `cornerPath` compensates for, and takes the mean of its two edges' opacity (rationale; mechanism documented at `cornerPath`). **Find each piece by `data-piece`, never by index.** +- **Two layers**, because one closed path cannot carry four widths (rationale): the smear is a sibling `` of eight pieces drawn underneath, and **never transforms or re-alphas the outline (``)**, whose own per-frame writes stay its `d` and its perimeter-fitted dash. +- **Eight pieces: four edges plus four corners**, every one cut from ONE shared point set (`ringPoints`) that `roundedRectPath` also walks, so the smear provably tiles the ring — pinned by `lib/src/lib/ring-geometry.test.ts`. A corner reaches two widths at once through a `scale` transform that `cornerPath` compensates for, and takes the mean of its two edges' opacity (rationale; mechanism documented at `cornerPath`). **Find each piece by `data-piece`, never by index.** - **Dash length is computed, never measured.** `ringPerimeter` returns the outline's exact length in closed form — straight runs plus `1.6232252401402307 × r` per corner. **Never substitute `π/2`** (the quarter-*circle* value: 3% short, silently shifting every dash) **or reinstate `SVGGeometryElement.getTotalLength()`** (a synchronous style+layout flush every frame) (rationale). - **Never reintroduce an SVG `feGaussianBlur` here** (rationale). @@ -344,7 +351,7 @@ Each pane body registers its DOM element in a `paneElements` Map on mount and re Re-measures on: selection change, target resize, scroll, window resize, Workspace changes, every Lath store commit, and each Lath animation frame. **Must hold the last painted frame when the target is missing, detached, or zero-sized**, including stale Door observer notifications during restore. Pinned by `restores from the last painted Door through a %s target` in `lib/src/components/wall/WorkspaceSelectionOverlay.test.tsx`. -Source of truth: `lib/src/components/wall/WorkspaceSelectionOverlay.tsx`, `lib/src/components/wall/resolve-pane-element.ts`, `lib/src/components/wall/use-window-focused.ts`. +Source of truth: `lib/src/components/wall/WorkspaceSelectionOverlay.tsx`; `resolvePaneElement` in `lib/src/components/wall/resolve-pane-element.ts`; `WindowFocusedContext` in `lib/src/components/wall/wall-context.tsx`, which the overlay reads and the Wall fills from `useWindowFocused` in `lib/src/components/wall/use-window-focused.ts`. ## Spatial navigation @@ -360,7 +367,7 @@ Source of truth: `lib/src/components/wall/WorkspaceSelectionOverlay.tsx`, `lib/s **`Cmd/Ctrl+Arrow` swap.** Swaps Surface **content** between two panes, leaving the layout shape unchanged. One Lath `swap` op trades the two leaf identities, and because per-leaf metadata and terminal-registry entries are keyed by id, title/params/session follow automatically — **never write a companion title swap** — with no DOM reattach. Selection stays on the moved Surface, so **the breadcrumb records the *partner*** (the pane now holding the old slot): the opposite `Cmd+Arrow` swaps back exactly and a plain opposite arrow selects the partner. -**Must ignore swap chords while non-pane chrome is selected**, including when a prior pane move left a breadcrumb (`handle-pane-shortcuts.test.ts`). Source of truth: `handlePaneShortcuts` in `lib/src/components/wall/keyboard/handle-pane-shortcuts.ts`. +**Must ignore swap chords while non-pane chrome is selected**, including when a prior pane move left a breadcrumb (`lib/src/components/wall/keyboard/handle-pane-shortcuts.test.ts`). Source of truth: `handlePaneShortcuts` in `lib/src/components/wall/keyboard/handle-pane-shortcuts.ts`. ## Minimize and reattach @@ -370,7 +377,7 @@ Source of truth: `lib/src/components/wall/WorkspaceSelectionOverlay.tsx`, `lib/s **A runtime Door is `{ id, token }` and carries no metadata.** Title, params and parked-ness stay in the Lath store, which keeps changing while the Surface is Doored, so no copy can go stale: **every reader — reattach, `dor` param matching, kill/session teardown, `dor list`, the baseboard chip's label, the session save — goes through `lath.getMeta(id)`**, and the persisted `PersistedDoor` row is materialized from the store at save time. -**A minimized browser Surface parks rather than unmounting** (`shouldParkOnMinimize`); terminals do not. `docs/specs/tiling-engine.md` → "Parked leaves" owns the mechanism, who parks, and the visibility contract. +**A minimized browser or Tool Surface parks rather than unmounting** (`shouldParkOnMinimize`); terminals do not. `docs/specs/tiling-engine.md` → "Parked leaves" owns the mechanism, who parks, and the visibility contract. ### Reattach (click door, `Enter`/`m`/`d` on door, or drag out) @@ -386,7 +393,7 @@ A door dragged out of the baseboard skips the token entirely and inserts at the Triggered by `,` in command mode or by clicking the session name in the pane header. -**Must consume `,` without starting a rename on a Door or browser Surface.** Only a terminal pane mounts the title editor. Pinned by `handle-pane-shortcuts.test.ts`. +**Must consume `,` without starting a rename on a Door or browser Surface.** Only a terminal pane mounts the title editor. Pinned by `lib/src/components/wall/keyboard/handle-pane-shortcuts.test.ts`. The name `` is replaced by an `InlineEditInput` (shared with the browser URL editor in `docs/specs/dor-browser.md`): same font (`font-mono font-medium`), `bg-transparent`, no border, seeded from the label with the failure glyph stripped. `Enter` confirms, `Escape` cancels, `blur` confirms — **whichever lands first settles the edit**, so the blur following an Enter/Escape unmount cannot submit a second time. It stops propagation on `mousedown`/`click`/`keydown` so the panel click and the header drag never fire. @@ -407,7 +414,7 @@ Source of truth: `lib/src/components/wall/IllegalRenameWarning.tsx`, `lib/src/co | **Restore** `restoreTerminal` | Creates the xterm entry and spawns a new PTY with the saved cwd; **replays no transcript** (`docs/specs/transport.md` → "What is persisted"). Cold start from a saved Snapshot (Link: Cold → Live). | | **mount / unmount** | `mountElement` reparents the persistent DOM element into a container, `unmountElement` removes it. **The Registry entry survives**, and **neither fits the terminal** — the caller owns fitting ([Animations](#animations)). | | **Dispose** `disposeSession` | Kills the PTY, disposes xterm, removes the registry entry on kill or Surface replacement. **Never on minimize.** | -| **Swap** | `Cmd/Ctrl+Arrow` trades two leaf identities via a Lath `swap`; registry entries follow the ids ([Spatial navigation](#spatial-navigation)). | +| **Swap** | Registry entries follow the traded leaf ids ([Spatial navigation](#spatial-navigation)). | - **Untouched**: new `getOrCreateTerminal` sessions start untouched; `isUntouched(id)` exposes the flag, user-originated PTY input clears it, and resume/restore seed the persisted one. **Missing legacy snapshot data defaults to touched (`false`)**, keeping close confirmation conservative. - **Shell selection replacement**: the standalone Settings dialog's Shell row and the VS Code shell picker send `dormouse:new-terminal` with `replaceUntouched` when the selected shell type changes. **A shell is identified by executable path plus ordered arguments**, so WSL distributions and Windows Developer shells sharing an executable stay distinct. **`Wall` always mints a new session id and a fresh `surface:N` ref.** An untouched selected plain terminal pane or door has the new terminal take over its leaf via a Lath `replace` op (an atomic identity swap; doors reattach through the normal restore path first), the old session disposed and its ref retired; a touched selection, or none, spawns a new pane beside it. Announced spawns show a transient pane-anchored notice (`Switched to zsh`, `Opened bash`). **A replacement migrates the Surface's notepad to the new id rather than archiving it** (`docs/specs/notepad.md` → "Closure"). @@ -471,48 +478,44 @@ Renderer Activity storage is owned by `docs/specs/alert.md` → Public State. ## Animations -All pane motion is owned by the Lath **animator**, applied imperatively to the leaf divs by LathHost (`docs/specs/tiling-engine.md` → "Animation"): 440ms `cubic-bezier(0.22, 1, 0.36, 1)`, a 0 duration under reduced motion. **There are no CSS entrance/exit classes.** Those leaf divs carry the interpolated inline geometry, which is what lets the selection overlay measure the tween ([Position tracking](#position-tracking)). **Never resize terminals to intermediate animation or sash-preview dimensions.** Fit after the final geometry is painted, including a sash commit at its last preview size; canceled sash drags preserve the original grid, and same-size reattachment sends no PTY resize. Outside layout motion, debounce container resizes by 150ms; unmount cancels pending fitting (rationale). +All pane motion is owned by the Lath **animator** (`docs/specs/tiling-engine.md` → "Animation"), whose interpolated inline geometry on the leaf divs is what lets the selection overlay measure the tween ([Position tracking](#position-tracking)). **Never resize terminals to intermediate animation or sash-preview dimensions.** Fit after the final geometry is painted, including a sash commit at its last preview size; canceled sash drags preserve the original grid, and same-size reattachment sends no PTY resize. Outside layout motion, debounce container resizes by 150ms; unmount cancels pending fitting (rationale). Source of truth: `TerminalPane` in `lib/src/components/TerminalPane.tsx`; `TerminalResizeContext` in `lib/src/components/wall/wall-context.tsx`, supplied by `LathHost` in `lib/src/components/wall/LathHost.tsx`. Tests: `lib/src/components/TerminalPane.test.tsx`. ### Zoom (elevated expansion) -**Zoom is presentation-only** — the split tree and every tiled rect stay unchanged; the geometry (the 15px-inset wall rect, the elevated layer, the blurred app-bg halo) belongs to `docs/specs/tiling-engine.md`. **Zoom is coupled to passthrough focus**: acquiring it enters passthrough and focuses that pane; exiting passthrough, focusing another pane, or selecting a Door starts unzoom immediately. +**Zoom is presentation-only** — the split tree and every tiled rect stay unchanged; the geometry (the 15px-inset wall rect, the elevated layer, the blurred app-bg halo) belongs to `docs/specs/tiling-engine.md`. **Zoom is coupled to passthrough focus**: acquiring it enters passthrough and focuses that pane; exiting passthrough, focusing another pane, or selecting a Door or a Workspace tab starts unzoom immediately. -**Only the owner's header shows Unzoom**, header tokens inverted so the escape action stands out, and only the owner's control toggles zoom *off*. The exposed perimeter leaves other headers reachable, so their Zoom control **hands zoom over — focus included** — rather than merely unzooming the owner. Source of truth: `ZoomedIdContext` in `lib/src/components/Wall.tsx`, `paneZoomButtonClass` in `lib/src/components/design.tsx`. +**Only the owner's header shows Unzoom**, header tokens inverted so the escape action stands out, and only the owner's control toggles zoom *off*. The exposed perimeter leaves other headers reachable, so their Zoom control **hands zoom over — focus included** — rather than merely unzooming the owner. Source of truth: `zoomedId` / `setZoomed` in `lib/src/components/wall/lath-wall-store.ts`; `onZoom` / `releaseZoomExcept` in `lib/src/components/Wall.tsx`; `ZoomedIdContext` in `lib/src/components/wall/wall-context.tsx`; `paneZoomButtonClass` in `lib/src/components/design.tsx`. ### Spawn (new pane reveal) -A newly added leaf enters by growing from the boundary it was placed against, at opacity 0 → 1. The store's mutators derive this **enter hint** from the edge they commit; **the auto-spawn refill overrides it to `'top-left'`**, since the killed last pane shrank toward the bottom-right (`docs/specs/tiling-engine.md` → "Animation" → Enter). +A newly added leaf grows in from the boundary it was placed against; `docs/specs/tiling-engine.md` → "Animation" → Enter owns the hint and its precedence. Shell-selection replacement shows a short fixed-position notice over the resulting pane, fading in/out over 1500ms via `.shell-spawn-notice`, suppressed to a static render under reduced motion. ### Kill (two-phase fade + tween reclaim) -`killPaneImmediately` in `Wall.tsx` runs the animator's two-phase exit — fade in place, then commit the removal after `lath.exitMs` so survivors tween into the reclaimed space. `docs/specs/tiling-engine.md` → "Animation" → Exit owns the mechanics, the idempotence guard, and the last-pane bottom-right shrink. +Every kill gesture runs the animator's two-phase exit; `docs/specs/tiling-engine.md` → "Animation" → Exit owns the mechanics, the idempotence guard, and the last-pane bottom-right shrink. **Selection tail.** At removal time selection moves to a survivor (`lath.listPanes()[0]`, or `null` → auto-spawn when the last pane goes) **only when the killed pane is still the selected pane** — a live check, re-read inside the removal timeout, so a background kill leaves selection untouched and a selection move *during* the fade is honored both ways (rationale). -**A doored Surface has no visible pane to fade**, so `killPaneImmediately` branches: close any agent-browser session, `forgetLeaf` (which also unmounts a parked DOM), `disposeSession`, drop the door chip. Disposing stops the PTY, which also makes a still-armed `typeCommandWhenPromptReady` bail rather than type into a dead surface. +**A doored Surface has no visible pane to fade**, so the kill branches: close any agent-browser session, `forgetLeaf` (which also unmounts a parked DOM), `disposeSession`, drop the door chip. Disposing stops the PTY, which also makes a still-armed `typeCommandWhenPromptReady` bail rather than type into a dead surface. + +Source of truth: `killPaneImmediately` in `lib/src/components/Wall.tsx`. ### Auto-spawn refill A store commit that empties the tree (last pane killed or minimized) triggers the "always keep one pane visible" auto-spawn: a Wall effect subscribed to the store spawns one leaf into the emptied tree (`lib/src/components/Wall.tsx`), **re-entrantly on the same commit chain**, so the refill appears with no separate delay (rationale). It spawns with the current default shell selection, matching manual splits. -**The refill adopts the replacement (`selectPane`) only when the current selection points at nothing real** — null (the kill tail cleared it after a selected last-pane kill) or dangling (still naming the just-removed pane). **A valid selection is left alone** — the just-created door on the minimize path, or a live pane after an unselected kill — because the auto-spawn exists to keep a pane visible, not to steal selection. +**The refill adopts the replacement (`selectPane`) only when the current selection points at nothing real** — null (the kill tail cleared it after a selected last-pane kill) or dangling (still naming the just-removed pane). **A valid selection is left alone** — the just-created door on the minimize path, or a live pane after an unselected kill — because the auto-spawn exists to keep a pane visible, not to steal selection. Only an explicit user selection of a pane — a click, a drag, or an embed focusing itself — moves selection off that door afterwards. ## Corner cases -> Numbered for cross-spec reference; the numbers are stable, so append rather than renumber. +> Numbered for cross-spec reference; the numbers are stable, so append rather than renumber and leave a retired one retired. -1. **xterm steals Meta keys**: the mode-exit gesture listens in the capture phase, so it fires even while xterm has DOM focus. -2. **A focused iframe surface is not a window blur**: it blurs the window while `document.hasFocus()` stays true, so **cross-session attention is cleared only on a *real* blur** — otherwise focusing an embed would wipe attention across the Wall. -3. **Stable hitboxes across moves**: a leaf measured after a move reports its new rect ([Position tracking](#position-tracking)), and Lath never re-parents a leaf div, so its node identity — and any embedded `