ui-spa · git:20260927.655ed8d · 2026-09-27 · sha256 ce4b208d0d124a25
ui-spa git:20260927.655ed8dA
Immutable. This exact content never changes and is served at /api/v1/blob/ce4b208d0d124a25.
---
description: Embedded SPA chat UI conventions (thinking row, context meter, rebuild)
globs: external/ui/**/*
alwaysApply: false
---
# Embedded UI (`external/ui`)
- Rebuild **`go:embed`** assets after UI changes: **`make build TAGS="http ui"`** (runs **`ui-build`**).
- **Thinking disclosure row** - duration must sit **next to** the **thinking** label, not at the trailing edge of the column. Markup keeps **`.thinking-dur`** inside **`.thinking-left`** in **`ThinkingMessage.tsx`**. Styles use **`.thinking-left { gap: 0 5px; }`** in **`styles.css`**. Do not drive label vs timer spacing with **`justify-content: space-between`** on **`summary.thinking-summary`**.
- **Workspace context chips** (**`WorkspaceChips.tsx`**, first child of **`.composer-card`**) - folder / branch pills plus a **worktree checkbox** (real **`input[type=checkbox]`**, **`composer-worktree-checkbox`**), backed by **`GET /coddy/workspace/context`** and **`POST /coddy/sessions/{id}/workspace`**. Keep the pure logic in **`chat/workspaceContext.ts`** and the MRU recents in **`chat/workspaceRecents.ts`** (do **not** inline them). The folder menu is Claude Desktop style: **Recent** rows (current marked **✓**) + **`Open folder…`** opening the **`WorkspaceFolderModal.tsx`** filesystem browser (rows navigate, **Open** picks the browsed folder). The branch chip renders only when **`is_git_repo`**; the checkbox is checked+disabled when the session already runs in a linked worktree. **Chosen once**: with a non-empty transcript the chips lock (**`workspaceLocked`**) and the server returns **409**. Menus reuse the **`mode-menu`** family: anchored portal on desktop (**`opens-down`** on hero, **`opens-up`** docked), bottom sheet on **`isMobileShell`**. Pre-session picks are pending in **`App.tsx`** (**`pendingWorkspaceRef`**) and applied on first send before **`POST /v1/responses`**. Contract: **`DESIGN.md`** (**Composer workspace chips**), **`docs/surfaces/web-ui.md`** (**Per-session workspace**).
- **Composer context ring** (indicator left of Send in **`Composer.tsx`**)
- The ring itself is **only** a stroked arc (relative context fill). Do **not** place a numeric percent label inside or on top of it. Percent usage and token counts belong in the tooltip only.
- **Idle home** (**`contextIdle`** when there is **no** `sessionId`): keep the arc at **zero** fill. Tooltip body is **exactly**: first line **`No context usage yet`**, second line **`Max context <n>`** (no session usage lines, **no** model name line).
- With an active session (including hero with **`#/s/...`** in the hash): show fill from **`tokenUsage`** vs **`maxContextTokens`**. Tooltip lists percent line, optional Input/Output/Total line(s) when **`tokenUsage`** is present, then Max context line. Do **not** add a **`Model …`** line in this tooltip (**Mode** exposes **`agent`** / **`plan`**; **`Model`** is the YAML backend pill next to it).
- Tooltip **presentation**: reuse the **`rail-tip`** styling (same family as narrow **navbar** hints). Anchor it **above** the ring, **horizontally centered** on it, with a **comfortable width** (use **`composer-context-tip-host`** / **`composer-context-tip`** rules in **`styles.css`**; avoid a cramped single-column tooltip).
- Prefer no extra chrome on the meter (no bordered "button tile" behind the SVG); **`.context-ring`** styling stays minimal. Stroke colors come from **`--coddy-context-ring-inner`** / **`--coddy-context-ring-fg`** (defined per theme in **`styles.css`**).
- **Transcript window** (issue #338; **`DESIGN.md`**, **Transcript window**; **`docs/surfaces/web-ui.md`**, **Long sessions**) - a long session is read page by page: **`loadMessages`** opens on the newest page (**`?limit=`**) and re-reads the live window it holds (**`?from=<offset>`**), never the whole history; **`loadOlderTranscript`** puts the page above (**`?limit=&before=`**) into **`olderTranscript`**, which no reload or merge touches and which is dropped when the reader is back at the newest message and the session is idle (**`slideTranscriptToTail`**); a large live window also starts over at the end of a turn (**`rebase`** in **`reconcileEndedTurn`**). A row with a focused text field and the tail while a permission or question prompt waits are never trimmed (**`pinTail`**). Page mapping lives in **`chat/transcriptFromMessages.ts`** (ids and notices numbered from the page's **`window`**), the window types and queries in **`chat/transcriptWindow.ts`**. On screen, **`chat/TranscriptList.tsx`** (**`useTranscriptWindow`**, arithmetic in **`chat/transcriptRenderWindow.ts`**) renders a bounded slice and keeps the reader's row still; its state stays there so a growing frame does not re-render the composer. Every row root carries **`data-row-id`** and is a direct child of **`.messages-inner`**; an edit's index is **`userMsgIndexBase`** plus the prompt's position. Browser check: **`npm run check:transcript`**.
- **Transcript scroll-to-bottom** (**`ScrollToBottomButton.tsx`**, rendered by **`ChatScreen`** inside **`.chat-bottom-inner`**) - a round control above the composer that takes the reader back to the newest message. Keep the arithmetic in **`chat/transcriptScrollPosition.ts`** (**`TRANSCRIPT_BOTTOM_THRESHOLD_PX`**, **`isTranscriptAtBottom`**, the metric and bottom readers, **`transcriptJumpDurationMs`**, **`easeTranscriptJump`**) and do **not** inline it again: one reading drives the stick-to-bottom follow, the button and the jump, on both scroll surfaces (**`.chat-scroll`** on wide shells, the document below **`1200px`**). The button **never unmounts** while a chat is open - **`is-visible`** toggles the transition, plus **`inert`** and **`tabIndex=-1`** when hidden. The jump runs in **`requestAnimationFrame`** (not **`scrollTo({behavior:"smooth"})`**), re-reads the end every frame so a streaming turn does not leave it short, and is cancelled by any **`wheel`** / **`touchstart`** / **`mousedown`** from the reader; **`syncTranscriptPosition`** must keep standing back while a frame is pending, or the button flashes back on for every frame above the band. The jump **never moves up**: an end at or above the current position writes nothing. On the stacked shell the document metrics read the **visual viewport** (**`visualViewport.height`** / **`offsetTop`**), because an overlaying on-screen keyboard (iOS Safari ignores **`interactive-widget=resizes-content`**) leaves **`innerHeight`** unchanged; the fixed docked block sits on **`bottom: var(--coddy-keyboard-inset, 0px)`**, which **`ChatScreen`** sets from **`keyboardInset`** on the visual viewport's **`resize`** / **`scroll`**. Do not measure the document against **`innerHeight`** again. Contract: **`DESIGN.md`** (**Transcript scroll-to-bottom button**), functional checklist: **`docs/surfaces/web-ui.md`** (**Transcript scroll-to-bottom**).
- **Send/stop circle** (**`Composer.tsx`** **`#btn-send`**) - **`composer-icon`** is a **perfect circle** (**`border-radius: 50%`**, square box). The shared **`composer-run-icon`** class is also used by scheduler run/stop buttons. **Play** is an **SVG triangle**, **17x17px**, never a text glyph; **stop** uses **`.composer-stop-square`** (**14x14px**, centered). Ring + stop stay in **`composer-bar-actions`** on the right (**`DESIGN.md`**, Composer primary action).
- **Model selector menu** (**`Composer.tsx`**, **`mode-menu--llm`**) - backend ids are **`vendor/model`**. Keep the filter/group/threshold logic in **`chat/llmModelMenu.ts`** (do **not** inline it): vendor headers (**`mode-menu-group-label`**) render only when more than one vendor is present (**`shouldGroupLlmModels`**); a filter input (**`mode-menu-filter`**, **`data-testid="model-menu-filter"`**, auto-focused) renders only when the backend count exceeds **`LLM_MENU_FILTER_THRESHOLD`** (5) and matches vendor / model name / full id case-insensitively (**`filterLlmModels`**); rows scroll under a ~5-row cap (**`mode-menu-scroll`**, **`max-height: min(175px, 50vh)`** in **`styles.css`**). Rows show **`displayLlmId`** (model name only) with the full id in **`title`**; **Enter** picks the first match, **Escape** closes, empty result renders **`model-menu-empty`**.
- **Mobile menu sheet** - on narrow shells (**`isMobileShell`** via **`shellBreakpoint.ts`**, **`max-width: 1199px`**) the **`Mode`** / **`Model`** / **`Reasoning`** portal menus render as a **full-width bottom sheet** (**`mode-menu--sheet`**, same family as the slash/at picker sheet) over a dimmed scrim (**`mode-menu-backdrop--scrim`**), not the anchored **`mode-menu--portal`** dropdown. Drive this from **`menuUseSheet = isMobileShell`** in **`Composer.tsx`**; the sheet overrides the desktop **`mode-menu--llm`** width cap and uses a **`46vh`** scroll cap. Keep the anchor positioning only on the desktop portal branch.
- **Tool call timer** - while an unresolved **`permission_prompt`** references a tool call id, **`ToolCallMessage`** freezes the **`thinking-dur`** label (**`permissionWaiting`** via **`permissionPendingToolCallIds`**).
- **Permission after reload** - SSE rows persist in **`localStorage`** (**`permissionPromptSessionStore.ts`**); pending **`run_command`** / fs tools without a tool result also get a synthetic **`permission_prompt`** on **`GET .../messages`** merge (**`restorePermissionPrompts.ts`**). Stop glyph is **`.composer-send-glyph` > `.composer-stop-square`** (never both classes on one node).
- **Composer auto-focus** - the app focuses the composer by itself (start screen, a conversation switch, History closing) only through **`composerAutoFocusAllowed()`** (**`chat/composerFocus.ts`**): never on a **touch-only** device, where focus opens the on-screen keyboard. A new place that focuses the composer without a tap goes through the same check.
- **Composer keyboard shortcuts** - what Enter does follows the **input device**, never the width: decide it with **`composerEnterAction`** in **`chat/composerEnter.ts`** (do **not** inline the rule again). With a keyboard (a narrow desktop window included) **`Enter`** and **`Cmd+Enter`** send; **`Shift+Enter`** is the browser's newline (not intercepted); **`Ctrl+Enter`** / **`Alt+Enter`** insert a newline at the caret through **`insertNewline`** + **`props.onChange`**, because browsers insert nothing for them. On a **touch-only** device (**`touchOnly`**, **`useSyncExternalStore`** over **`subscribeTouchOnly`** from **`shellBreakpoint.ts`**, query **`(any-hover: none) and (any-pointer: coarse)`**) Return stays the browser's newline and send is the button; **`enterKeyHint`** is **`enter`** there and **`send`** elsewhere. Enter during IME composition (**`nativeEvent.isComposing`** or **`keyCode` 229**) and a repeating held Enter never send; a composing key returns at the top of the composer's **`onKeyDown`**, before the pickers and the Ctrl+Z restore, so no picker takes a row, moves its highlight or closes on it either. There composing means **`isComposing`**, or keyCode 229 within **`COMPOSITION_END_KEY_WINDOW_MS`** of **`compositionend`** (Safari's order, tracked by **`compositionEndedAtRef`**, consumed by the next keydown, dropped by a new composition, and ignored by a keydown 100 ms or more after it); any other 229 is an ordinary Android key and must keep working the pickers. **`isMobileShell`** (the **`max-width: 1199px`** width query) still drives the bottom-sheet menus, not the keyboard. Outside composition, the picker **`Enter`** handlers (slash, at and command option menus) take precedence and run before this rule on all devices.
- **Multimodal model flag** - **`GET /v1/models`** exposes **`multimodal: bool`** per entry from YAML **`models[].multimodal`**. **`App.tsx`** reads it into **`ModelInfo.multimodal`**, derives **`llmModelMultimodal`** (**`useMemo`** over current **`llmModel`**), and passes it through **`ChatScreen`** → **`Composer`** as **`llmModelMultimodal?: boolean`**. Only render file attachment UI (file picker button, attachment previews) when **`llmModelMultimodal`** is **`true`**; keep the prop optional so the component degrades gracefully when models are not configured. After a successful **`PUT /coddy/config`** save, **`Settings`** fires **`onConfigSaved`** → **`App.tsx`** bumps **`configEpoch`** → re-fetches **`/v1/models`** so the attachment button appears without a page reload.
- **Live config reload** - the SPA reads every config-derived list once, at boot, so a model added mid-session is invisible until a page reload unless the server says otherwise. **`GET /coddy/events`** carries **`event: config_reloaded`** after **every** swap of the live configuration (the server publishes it from **`Server.ReplaceConfig`**, so the settings form, the agent's **`config_commit`** / **`config_rollback`** and a skill install are covered by one choke point). **`chat/serverEvents.ts`** parses it into the optional **`onConfigReloaded`** callback, delivered by **`subscribeSharedServerEvents`** (see the next rule); **`App.tsx`** bumps **`configEpoch`**, and every config-derived fetch depends on that counter - today **`GET /v1/models`** and **`GET /coddy/slash-commands`**. Add new config-derived fetches to the same counter rather than to a second one. The **Settings** drawer is the one reader outside that counter: its schema and config are the copy **`settings/settingsConfigStore.ts`** keeps for the life of the page (read on the first open only, never on every mount, which drew the drawer empty and rebuilt it - issue #359), and **`noteSettingsConfigReloaded`** reads that copy again on the event and on every reconnect of the stream. An open form takes a new copy only while it holds no edits of its own, and it takes it **while rendering**, never in an effect after it (a frame from an empty document makes a list read the address's **`?id=`** as a stale row and rewrite it); unsaved edits stay, and the form's **Reload** control is the deliberate way to drop them. A save puts the document it wrote into the copy at once (**`noteSettingsConfigSaved`**): a copy older than a save must never be drawn again, or the next save from it puts the replaced values back. On the server side the order inside **`ReplaceConfig`** is the contract - swap the live pointer, drop any cache derived from it (today the per-workspace slash-command cache), *then* announce - because a client re-reads the instant it sees the event and must never be handed the outgoing answers.
- **Shared events stream** - tabs of one environment share a single **`GET /coddy/events`** connection, because a browser keeps six HTTP/1.1 connections per host for all of its tabs and a stream per tab starved every other request once six tabs were open. **`App.tsx`** subscribes through **`subscribeSharedServerEvents`** (**`chat/sharedServerEvents.ts`**): a SharedWorker (**`chat/eventsWorker.ts`**, body in **`eventsWorkerHost.ts`**) where the browser has one, else the tab elected through Web Locks relaying on a BroadcastChannel, else a stream per tab. Do **not** open **`/coddy/events`** from anywhere else. A new event type goes into **`ServerEvent`**, **`parseServerEvent`** and **`dispatchServerEvent`** in **`serverEvents.ts`** - it must stay plain data, it crosses a MessagePort - and if the server replays it in the connect snapshot before **`ready`**, **`ServerEventsHub.track`** / **`hello`** in **`serverEventsHub.ts`** must replay it to a joining tab too. That module and the worker are loaded in a worker scope: no DOM, no **`window`**. The worker ships as the fixed file **`events-worker.js`**, listed in **`vite.config.ts`** (**`worker`**), **`scripts-sync-to-go.mjs`**, **`embed.go`**, the **`Dockerfile`** and **`TestEmbeddedUIPublicAssetsCacheControl`**; rename it everywhere or nowhere. Contract: **`docs/surfaces/web-ui.md`** (**Server events shared across tabs**).
- **File attachment flow** - Composer holds **`attachedFiles: File[]`** state; the hidden **`<input type="file">`** ref (**`data-testid="composer-file-input"`**) feeds **`setAttachedFiles`**. When sending, if files are present, **`onSend(text, files)`** passes them up; **`App.tsx`** reads each as a data URL (**`FileReader`**) and adds **`inline_files: [{name, data_url}]`** to the **`POST /v1/responses`** body. For **`agent`** / **`plan`** turns: the backend saves each file to `~/.coddy/sessions/<id>/assets/` with **`0o444`** permissions and injects a `<coddy_session_assets>` XML annotation into the user message content so the model can `read` or `cp` the files. The SPA strips this annotation from the display (and from copy-to-clipboard) via **`stripCoddyAttachmentsForUserDisplay`** in **`stripCoddyAttachments.ts`** and instead renders **file chips** (name + type icon) above the user bubble using **`msg-user-files`** / **`msg-user-file-chip`** CSS classes; **`parseSessionAssetFiles`** in the same file re-derives chip metadata from the XML on page reload so chips persist. For direct YAML model: each entry becomes an `image_url` content part sent inline to the provider. Multiple files in one request are supported; duplicate asset names are disambiguated with `_1`, `_2` suffixes by **`SavePartsToAssets`** in `internal/session/assets.go`.
- **Server images in a remote environment** - an **`<img>`** does not go through the **`fetch`** shim, so a picture the server names by an API path (a user message's **`files[].preview_url`** / **`url`**, anything under **`/coddy/`** or **`/v1/`**) is shown through **`useApiImageSrc`** (**`external/ui/src/ui/env/apiImage.ts`**), never put in **`src`** as it is: in a remote environment or through a swarm relay it is fetched with the environment's **`remoteApiRequest`** (base URL in front, token in the header, never in a URL) and shown from an object URL released on unmount. Checked by **`UserMessage.test.tsx`**, **`apiImage.test.ts`** and **`npm run check:queue`** (the thumbnail and the original through the relay).
- **Layout grid** (**`DESIGN.md`**, **Layout grid**) - four tiers by viewport width: **phone** up to **599px**, **tablet** **600-1199px** (the stacked shell), **desktop** from **1200px**, **wide** from **1920px**. Every width query in **`styles.css`** and every **`matchMedia`** is a tier edge or a component threshold the grid lists by name (**640px** plan document card head, **700px** swarm screen padding, **761px** desktop rail pill, **1279px** documentation outline); the code reads the edges from **`shellBreakpoint.ts`** (**`PHONE_MAX_WIDTH_PX`** / **`phoneMaxWidthMediaQuery`**, **`SHELL_STACK_MAX_WIDTH_PX`**, **`WIDE_RAIL_MIN_WIDTH_PX`** / **`wideRailMinWidthMediaQuery`**) and never spells a width into **`matchMedia`**. A new width is a new row of the grid's component table first: **`layoutGridCss.test.ts`** reads that table as its allow-list (direction included) and fails on any other width, on range syntax or non-px units, and on a row no rule uses. A phone rule goes under **`@media (max-width: 599px)`**, never a threshold of its own (the phone rules used to be split between 480px and 520px).
- **Transcript containment** - nothing in the transcript is wider than the transcript, at any width: on the stacked shell its column is the page. A tool row's **`.thinking-head`** wraps (**`flex-wrap: wrap`**): **`.thinking-head .thinking-label`** is **`flex: 0 0 auto; max-width: 100%; overflow-wrap: anywhere`**, and the target, the failure marker and the duration sit in one group, **`.thinking-trail`** (**`flex: 1 1 7em; min-width: 0; max-width: max-content`**, **11em** with **`.thinking-trail--failed`**), which moves under the label as a whole when the label's line lacks that room; the target inside is **`flex: 0 1 auto; min-width: 0`** with the ellipsis. Do not put the three back into a non-wrapping unit, let the target drop under the label on its own (it left a one-pixel target and a lone duration), drop the group's **`max-width: max-content`** (a short group then wraps under a label it fits beside) or its **`min-width: 0`** (the automatic minimum is the target's whole text). **`.md`** carries **`overflow-wrap: anywhere`**; **`.md-inline-code`** is an 18px chip of a **14px** line box plus **3px 7px 1px** padding, **`max-width: 100%`**, wrapping inside itself. Contract: **`transcriptWrapCss.test.ts`**; live check **`npm run check:overflow`** (**`scripts/phone-overflow-check.mjs`** over **`src/phone-overflow-check.html`**) at every width of the grid.
- **Mobile MQ helpers** (**`shellBreakpoint.ts`**) - **`subscribeShellStack`**, **`snapshotShellStack`**, **`serverSnapshotShellStack`** (the **`max-width: 1199px`** layout query) and **`subscribeTouchOnly`**, **`snapshotTouchOnly`**, **`serverSnapshotTouchOnly`** (the input-device query **`touchOnlyMediaQuery`**) are exported for **`useSyncExternalStore`** use anywhere in the SPA. Layout follows the width, keyboard behaviour follows the device. Do not duplicate these in component-local functions.
- **Phone layout** (**`max-width: 599px`**, the phone tier of the grid) - one block at the **end** of **`styles.css`** (after the stacked-shell rules it narrows), nothing in it applies above **599px**, so tablets and desktops keep their layout; the Tasks control, the permission card and the live status line narrow themselves under the same query next to their own rules. Top bar: **`.rail-brand-sub`** hidden, icons **40px** with a **4px** gap, **`.rail-brand`** and its wrapper **`.rail-brand-tip-host`** (the bar's actual flex item) shrink and clip (**`flex: 0 1 auto; min-width: 0; overflow: hidden`**) while **`.rail-middle`** grows but never shrinks (**`flex: 1 0 auto`**, icons at the right edge), so icons never paint over the brand. What does not fit folds behind **More** (**`nav-more`**): keep the split in **`nav/navOverflow.ts`** (**`splitNavItems`**, **`navSlots`**; History and Swarm never fold, return order Settings, Scheduler, Docs, Sign out, menu order Docs, Scheduler, Settings, separator, Sign out) and the measurement in **`NavRail`** (stacked shell only, pill width minus the brand's natural **`scrollWidth`**, so the answer does not depend on what is showing). Composer: **`.composer-tabs`** and **`.composer-context-scroll`** are one-line strips that scroll sideways under a right-edge fade with a trailing spacer; **`.composer-bar-actions`** (ring + Send) and the improve-prompt button stay outside the strips and never shrink. **`.composer-context-scroll`** wraps the environment and workspace chips in **`Composer.tsx`** and is **`display: contents`** above the phone width, which keeps the wide shell's single wrapping row. **`.hero`** is **`grid-template-columns: minmax(0, 1fr)`** everywhere, so the start screen never widens the page. Settings tile names (**`.settings-tile-title`**) wrap to two lines instead of an ellipsis, since a phone has no hover for the tooltip. Text fields are at least **16px** under **`(max-width: 599px), (any-hover: none) and (any-pointer: coarse)`** (iOS Safari zooms into smaller ones), the composer textarea and **`.composer-mirror-inner`** together. Contract: **`phoneLayoutCss.test.ts`**, **`features/web_ui_phone.feature`**, **`DESIGN.md`** (**Phone layout**); live check at **360** / **375** / **393** / **430px**: no horizontal page scroll, Send intersects no chip, the brand intersects no icon.
- **Settings form layout** - a settings tab is fieldsets by meaning, never a column of loose fields. Lay a schema form out with **`SchemaForm`** **`groups`** (**`objectSectionGroups`** in **`settings/SettingsSection.tsx`** for the object tabs): the section's own **`enable`** switch opens the form outside every fieldset, a list or a nested object is a block of its own beside the groups, and a group stands where its first field stands. A list of plain values is bare inputs with a trash each and **Add** below (no **`dirs[0]`** label, no rule between rows); an entry of a list of objects is a frame without a legend. The tab order is **`rootOrder`** in **`internal/config/ui_schema.go`**, grouped by meaning, with the **Prompts** tab (id **`system`**) last. Contract: **`DESIGN.md`** (**Section form layout**), tests **`SchemaForm.groups.test.tsx`**, **`settingsSections.test.ts`**.
- **Settings field descriptions** - a settings form shows names and controls; a field's or a fieldset's description goes behind the **(i)** beside its name, never into a paragraph under it. Use **`FieldLabel`** (label + description), **`LegendWithHint`** (fieldset legend + description) or **`SwitchField`** (its **`description`** prop), all from **`settings/FieldHint.tsx`**; do **not** render **`<p className="settings-field-desc">{description}</p>`** for a description again. The tip is portalled to **`<body>`**, **`position: fixed`** above the (i), **`z-index: 1000`**: never move it back inside the field, where **`.settings-scroll`** clips it or grows a scrollbar for it. Paragraphs are for state only (fetch results, sign-in codes, warnings about a stored value). Contract: **`DESIGN.md`** (**Field descriptions**), tests **`FieldHint.test.tsx`**.
- **Escape and the screens of the rail** - every rail item but Sign out opens a screen (History, Scheduler, Swarm, Docs, Settings), and **Escape** closes the one on top the way its **×** does. The rule lives once in **`nav/railEscape.ts`**: **`App.tsx`** calls **`useRailScreenEscape`** with a **`Record<RailScreenId, RailScreen>`** of every screen (open, close), so a new rail item does not pass **`tsc`** (**`make lint`**, the pre-commit hook, CI; **`vite build`** does not check types) without its entry, its **`RAIL_SCREEN_DEPTH`** and its row in the page map of **`App.railEscape.test.tsx`**. Do **not** give a screen an Escape handler of its own. Escape undoes one step: a control that answers Escape **claims** it (**`preventDefault`** or stopping it) and the screen takes only an unclaimed one; a screen with a step of its own (the head's back arrow) registers it with **`useRailEscapeStep(<screen>, step)`**, or, for state App owns (the scheduler's open job), the table entry's **`close`** is that step. The screens listen on **`document`** in the bubble phase, after the page's controls and before **`window`** (the chat under the screen), and stop the key they took; a menu, popover or dialog answers Escape through **`useEscapeCloses(open, close)`** (**`components/useEscapeCloses.ts`**: capture phase on **`document`** plus **`preventDefault`**), or the screen, listening since before it opened, closes too; a control that answers only while focused claims the key in its own **`onKeyDown`**. Held, modified and composing Escapes close nothing. Contract: **`DESIGN.md`** (**Escape and the screens of the rail**), scenarios **`features/web_ui_rail_escape.feature`**, harness for component tests **`nav/railEscape.fakes.tsx`**.
- Authoritative layout and tokens remain in the repo root **`DESIGN.md`**.