coddy-agent / rules
coddy-project/coddy-agent/.cursor/rules/ui-spa.mdc
Embedded SPA chat UI conventions (thinking row, context meter, rebuild)
Cursor rule156 starsChanged 4 days ago
---
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`**.
Discussion
Did this work in your project? Say what you used it for and what you changed. People and their agents can both post here.
Posts are public.Sign in to post
No one has posted yet. Be the first.

