# Interface

How the shell, the library, the panels, the styling surfaces and the command palette are assembled, and the focus, keyboard and theme rules they share; the document they read is described in [architecture.md](./architecture.md).

## Shell composition

`apps/canvas/src/App.tsx` renders the shell in one column. The title bar sits at the top, the board fills the middle, and the dock and the library drawer sit over its bottom-left corner. The inspector, the card focus and the tour overlay are children of the board area, so they open in the same stacking context as the canvas; the dock, the drawer, the palette and the dialogs are siblings that portal or float above it.

Three surfaces hold the chrome: the title bar, the navigation row inside it, and the dock. A board at rest shows nothing else over the canvas. Everything that used to float there (connection state, the saved state, the storage warning, the history toggle, the camera stack) is either a row in the sync panel or a control in the navigation row.

| Surface | Component | What it owns |
| --- | --- | --- |
| Title bar | `apps/canvas/src/chrome/TitleBar.tsx` | Workspace name and rename, the command bar, sync and storage status, the workspace menu, the account slot, the theme toggle |
| Navigation row | `BoardTabs.tsx`, `UndoRedo.tsx`, the history and outline buttons in `app/WorkspaceShellView.tsx` | Home, the open boards with their peer counts, undo, redo, version history and the outline, in one row |
| Sync panel | `apps/canvas/src/chrome/SyncPanel.tsx` | Connection state, unsent changes, this device's copy, the relay's room name, the storage warning and the storage surface, plus the plan wall and the retry for whoever just paid |
| Views bar | `apps/canvas/src/chrome/ViewsBar.tsx` | Saved views, applying one, renaming and deleting one |
| Dock | `apps/canvas/src/chrome/Dock.tsx` | Library toggle with the inbox count, new card, new sketch, new section, new portal, type settings |
| Outline rail | `apps/canvas/src/canvas/BoardOutline.tsx` | The active board's section tree, in reading order, with counts and presence. Hidden while the board has no sections |
| Inspector | `apps/canvas/src/cards/CardInspectorHost.tsx` | The one panel for the selected card or container |
| Blueprint install | `apps/canvas/src/app/BlueprintInstallHost.tsx` | The type a card's title and body declare, previewed before it is written |
| Library drawer | `apps/canvas/src/library/LibraryDrawer.tsx` | Every card, board and trashed record, with search and facets |
| Command palette | `apps/canvas/src/command/CommandPalette.tsx` | Navigation and actions from one query |
| Presence | `apps/canvas/src/presence/` and `apps/canvas/src/cursor/` | Peer cursors, name tags, avatars and the status cursor |
| Account | `apps/canvas/src/auth/SignInEntry.tsx` and `AccountPanel.tsx` | Sign in or the account menu, and one dialog for the account, members, devices and the plan |
| Launcher | `apps/canvas/src/launcher/WorkspaceLauncher.tsx` | Recents with rename, export and delete, import, and the same account entry the title bar has |

The shell holds no document data. Selection, the active board, open tabs, filters, the open surface and the palette query live in `apps/canvas/src/store/useUiStore.ts`; the document arrives through `useWorkspaceSnapshot`.

## The library

The drawer has four tabs: `inbox` (cards on no board), `cards`, `boards` and `trash`. `apps/canvas/src/library/libraryFilters.ts` defines the facets: type, tag and section, with OR inside a facet and AND across facets. The section facet appears only when the drawer is scoped to one board, because a section title means nothing outside its board.

Search and facets feed the same list. A saved view fills those same controls through `applyView`, so there is one filtering model and no second query language (ADR-0037).

Rows share one action menu, opened from the `⋯` button or a right-click, and one piece of state (`rowMenu`), so both entry points cannot show different actions for the same row. A card row can be dragged onto the canvas: the hold and drop run through `useLibraryHold`, and the drop places the card at the pointer instead of at the drawer.

A row also holds an open button, and that button is the row's tab stop. Enter opens the card or the board; `ArrowUp`, `ArrowDown`, `Home` and `End` move between rows through `rowsKeyDown`; the `⋯` menu is how a keyboard reaches Place, Trash and the rest. The pointer gesture is unchanged: a press on the title still lifts the row for a drag, because the open button ignores pointer activations and only answers a keyboard click.

`library-sort` chooses title, recently updated or relevance, and `useLibraryList` applies it to whatever the facets and the query left. Relevance is the search ranking, so a list with no query keeps its facet order. A search that reaches the 200-hit cap in `hooks/useLibrarySearch.ts` says so above the rows, because a list that stops at 200 otherwise reads as complete.

Deleting a trashed card for good asks in the app's own `Dialog`, through `pendingPurge` in `LibraryDrawer`. `useLibraryRowActions` only reports the request, because the menu is data and has no component to host a dialog.

`libraryFull` widens the drawer to the whole window for browsing; the floating width stays the default because a row has to be draggable onto the canvas beside it. `LibrarySelectionBar` appears with the selection mode, not with the first row, and offers placing the selection as a run.

## Board navigation

Board tabs are a navigation, not a tab strip. Switching a board replaces the canvas, so the active tab carries `aria-current="page"`, and the strip can hold the close button and the inline rename field it needs without breaking required children. A back control appears when the visit history has somewhere to go; the history records the board and the viewport, so returning restores both.

The home board is a tab that is always reachable when it is not the active one. A portal's entry and the library's "go to board" both go through the same activation path, which is why the back stack exists for every route into another board.

Peers show on tabs as small avatars, aggregated per board, so "where is everyone" is answerable without opening the outline.

## Panels

One inspector exists. `CardPanel` renders in two variants, `inspector` (docked beside the canvas) and `embedded` (inside a card focus), and both read the same card model, so a property can be fixed without going to the canvas (ADR-0050).

Every kind that can carry an icon shows the same row: `apps/canvas/src/inspector/IconField.tsx`, one picker
with a button that names the current icon and a Clear that returns the entity to what it inherits. What the
value means is `resolvedIcon`'s job (see [canvas.md](./canvas.md#icons)); the row only decides how choosing
reads, which is why a card, a portal and a section cannot end up with three slightly different pickers.

What an inspector shows is one table, `apps/canvas/src/inspector/capabilities.ts`, not a branch per entity. A tab and a body row each declare the kinds they serve, and a surface asks for the ones that serve what it is describing: a card gets Details, Content and Style; a portal gets Details, Contents and Style; a section gets Details and Contents, because its paint is the one colour it owns and everything else about it is the theme. A kind that has nothing for a tab never sees the tab, and a mixed selection intersects rather than unions.

`InspectorTabs` draws the strip for all of them: one tab stop, arrow-key movement, and the selected tab the only one with `tabIndex=0`, so the panels differ in their body and not in their controls.

`ContainerPanel` covers the container selection (section or portal), `LinkInspector` covers one edge, and `ExplainPanel` shows the explain context. `TypeSettingsDialog` edits the type registry and its fields. Each of these writes through `@suite/graph`; none keeps a copy of the record it edits.

Two panels read a card's metadata without owning it. `apps/canvas/src/panels/AnnotationReader.tsx` shows the passage an annotation card points into, resolved against the source's text (ADR-0072). A record keeps the quoted text and the offsets it was captured at; the quote is the anchor and the offsets are a hint, so an edit above the passage shifts the range silently. Stale means one thing: the source no longer contains the quote at all, or the source card is gone. Only that state asks the reader to act. `apps/canvas/src/panels/BlueprintInstallPanel.tsx` shows what installing a type blueprint would write, one checkbox per field, and it reads its own footer from `planBlueprintInstall` rather than counting on its own.

## Hover peek

A rest on a rendered entity names it (ADR-0055). `apps/canvas/src/canvas/peek/peekResolver.ts` answers for one `Target` at a time (a card, a board, a section or a link) and returns `null` for a kind it cannot describe, so the surface opens nothing rather than guessing. A card reads the same way in the outline rail, in the inspector and on a link endpoint because all three ask the same resolver.

`usePeek` in `canvas/peek/usePeek.ts` holds the timing rules, which are the whole difficulty:

| Rule | Value | Why |
| --- | --- | --- |
| Open after a rest | `openAfterMs` 450 | Long enough to separate a passing pointer from a rest |
| Grace after leaving | `closeAfterMs` 120 | Lets the pointer cross the gap into the peek |
| A focus this soon after a press came from it | `pointerFocusGraceMs` 250 | A click selects; it does not peek |

Escape, a pointer press anywhere, a scroll and a zoom all dismiss, and those listeners live in the hook rather than in each surface, so no surface can forget one. Keyboard access is the same resolve: `focus` opens the peek at once, with no delay, and `PeekSurface` renders in place rather than through a portal so Tab from the trigger reaches its one button. The surface never takes focus itself, and it is a reading: a name, a kind, one line of context and a route to open the thing. Editing stays in the inspector.

The outline rail is the first surface wired to it: `BoardOutline.tsx` gives each section row a `peek` controller, and the rail renders one `PeekSurface` for all of its rows.

## Error codes

`apps/canvas/src/i18n/` ships codes before sentences. `codes.ts` is a closed union of `domain.subject.reason` strings, `messages.en.ts` is the English catalogue typed as `Record<ErrorCode, string>` so a code without a message fails the typecheck, and `messageFor` turns a code into a sentence with `{name}` placeholders filled.

An unknown code is returned as itself, which keeps a log line greppable and leaves the interface short rather than blank. There is one locale and no plural or number formatting yet; a second catalogue is a new file and one entry in `catalogues`, because nothing outside the catalogue holds a sentence.


## Selection

Selection is UI state with several readers: the canvas paints it, the inspector opens on it, and the commands that act on "this" resolve against it. `selectedNodeIds`, `selectedEdgeIds`, `selectedSectionId` and `containerInspector` in `useUiStore` are the whole of it.

A modifier click belongs to multi-select and must not open the inspector; the card's click handler returns early for shift, meta and ctrl, and the drag handler decides ownership on pointer down so pan and drag never both claim the same gesture.

While a link is being drawn, the inspector fades. The preview passes under the panel and the destination is usually the thing the panel covers, so `linkingInProgress` exists for that one layout decision and nothing about the draft is written to the document.

## What a press on a card does

Reading a board and editing it are the same click, so the click needs a mode. `InteractionModeToggle` sits beside undo and redo and cycles `interactionMode` in `apps/canvas/src/store/interactionMode.ts`:

| Mode | A press on a card | Neighbours |
| --- | --- | --- |
| `inspect` (default) | Focuses the text and opens the inspector | Everything as before |
| `quiet` | Focuses the text, the panel stays shut | The link count and the context menu's Card info still open it: a deliberate door is not the click |
| `locked` | Nothing | Cards cannot be dragged, selected, connected or edited, a double-click on the pane adds no card, and the board's key actions are silent; the canvas still pans and zooms, and Escape still works |

The mode is UI state, never document state, and it never touches a capability. A viewer was already restricted by the session matrix; the lock is a reader's own choice on top of what they may write, which is why `nodesDraggable` reads `canEditLayout && !locked` rather than replacing it.

`interactionModePatch` closes what the previous mode opened: leaving `inspect` puts the panel away, and `locked` also drops the caret and the selection, so a canvas being panned does not keep a card armed for the next keystroke. The control names the current mode rather than the next one, because an accidental press under `locked` reads as a broken card otherwise.

## Presence surfaces

Presence is drawn above the board and below the chrome. `PresenceCursor` shows a peer's pointer with their name, in flow coordinates so it stays on the right card at every zoom. `PeerNameTag` labels the thing a peer is manipulating and picks its own ink, because a presence colour is chosen by a person and a chip cannot assume white text clears the floor. `PresenceAvatars` stacks the people on a card and collapses the overflow.

`StatusCursor` in `apps/canvas/src/cursor/StatusCursor.tsx` is the single renderer for a pointer, and it says what the next click will do. The ring's shape and glyph carry the purpose, its colour comes from the reader's presence colour, and the bubble names both. It never relies on hue alone.

Marquee and drag boxes ride the same ephemeral channels as the pointer. They are `pointer-events: none` and never wrote a position; the document write happens on release.

## Dialogs and menus

Every overlay portals into `document.body`. A `position: fixed` menu rendered inside the canvas would be positioned against that transformed ancestor instead of the window, which puts it off screen; portalling is the reason a menu opens where the pointer is.

The board's right-click menu is one table too: `apps/canvas/src/canvas/board/menu/entityMenu.ts` holds rows, each declaring the kinds it serves, and a press resolves what it landed on and keeps the rows that serve it, drawn in group order (appearance, structure, layout, transfer, share, then anything destructive). A card, a portal, a section, a link and a connector therefore share a shape and differ only in which rows exist for them; a press inside a multi-selection resolves to the whole selection, which is why a verb can count (`Duplicate 4 cards`). `board/useBoardMenu.ts` builds the context and nothing else.

`ContextMenu` clamps itself to the viewport and flips a submenu to the left of its parent when the menu was opened near the right edge. A submenu opens on hover, on focus and on click, and a click never toggles it shut, because the pointer already opened it. It also takes focus on its first item when it opens, for the reason in [Focus and keyboard](#focus-and-keyboard).

`Dialog` is the confirmation shell: a title, optional description, and a list of actions marked primary or danger. The scrim is a labelled button, so a pointer press outside the panel is a dismissal a screen reader can also reach.

Every dialog that says `aria-modal="true"` traps Tab. `SignInDialog`, `AdmissionPanel`, `ShareDialog`, the publish conflict dialog in `SharePublishButton.tsx`, `SettingsDialog` and the shortcut sheet all call `useFocusTrap`, and `AdmissionPanel` starts on the answer it recommends ("Keep working locally") and closes on Escape. Card focus mode is a modal for the board rather than for the window: `CardFocusSurface` carries `role="dialog"` and the same trap, and a sketch's drawing surface takes the initial focus, because a sketch listens for keys and had none of them before.

Three questions that used `window.confirm` ask in the surface that owns them. Deleting a trashed card for good opens the library's `Dialog` through `pendingPurge`. Revoking every share link and reloading a draft both replace the control with an inline confirmation inside the dialog they belong to, so the thing about to be lost stays on screen.

One confirmation asks for more than a press. `DeleteWorkspaceDialog` requires the workspace name or id to be typed and reports a failure inline without touching the local copy. Leaving a workspace asks nothing: an in-app dialog and the browser's tab-close prompt both stood there once, and both were removed because they fired for signed-in readers whose work was already synced and could not be acted on. Whether this browser keeps the copy is reported by the storage ladder, in `StoragePanel`.

Storage is the one thing that warns without being asked, because a browser clears an origin's data without telling anyone. `apps/canvas/src/feedback/storageFill.ts` samples `navigator.storage.estimate()` as the document grows (a leading-edge throttle in `storage/estimateWatch.ts`, so a keystroke burst measures once) and toasts at two crossings: `FILL_THRESHOLDS.low` at 70% as a neutral line, and `FILL_THRESHOLDS.critical` at 90% as an urgent failure. Both offer one action, *Export a copy*, because a file survives a cleanup and nothing else on that page does. A crossing is news once: the stage has to fall back to `fine` and climb again before the same line is repeated. The rung-based warning for a copy the browser has not promised to keep is a separate rule, and it stays in the panel rather than interrupting.

`DisplayStyleDialog` edits one display template with preset buttons, a live preview through `ValueTemplate`, and a scope choice between this instance and the field.

## Styles and appearance

Style is document data, at two levels and no more. `apps/canvas/src/styles/surface/StyleTab.tsx` edits a card, a link or a type; a card wears its type's style and then its own, and everything else wears the theme. `apps/canvas/src/canvas/surfaceContext.ts` resolves the two levels and hands the result to `surfacePaintVars`, so a collaborator sees the same colours.

The same editor is mounted in three places: the inspector, the type editor in `apps/canvas/src/panels/TypeSettingsDialog.tsx`, and `apps/canvas/src/app/AppearanceHost.tsx`, which opens where a reader right-clicks. The menu's `Colour` row in `apps/canvas/src/canvas/board/menu/entityMenu.ts` adds the palette quick-picks to the same menu, so one colour is one click and a selection of cards is restyled in one.

Colours come from the workspace palette: `PaletteEditor` in Settings edits it as one list, each swatch carrying a light and a dark value, and both travel in the document.

A **shape** is the frame, not a decoration on one. `shapeClipPath` and `shapeOutline` in `apps/canvas/src/canvas/surfacePaint.ts` are the one table of silhouettes: the clip path is what the browser paints and the outline is what everything else reads — the connector dots (`silhouette.ts` projects a dot onto the paint), the inset that keeps a title inside a diamond (`shapeInset`), and the width of the title bar under it. Where a silhouette is not the box, the card's own border, shadow and selection ring all stand down, because they are rectangles and a diamond shows all three; the reader's feedback follows the paint as a drop-shadow glow instead. The edge editor collapses to one `Outline` row for the same reason: a diamond has no top edge to style apart from its bottom one.

Device appearance is local, and it lives in Settings. `apps/canvas/src/styles/SettingsDialog.tsx` is a modal dialog with a focus trap and Escape through the escape stack. One section is document data and one is not: `PaletteEditor` writes the workspace palette, and `AppearanceSection` sets density, card radius, canvas background and a motion preference, written by `apps/canvas/src/theme/appearance.ts` as attributes on `<html>` (`data-density`, `data-canvas`, `data-motion`) plus a `--reader-card-radius` variable. Nothing about it is synced, because it changes how this screen draws and never what a peer sees.

The dialog opens from the theme menu (`theme-appearance`, which reads "Settings…") and from the palette's `system.settings` row. Both go through `openSettings` in `apps/canvas/src/styles/settingsEvents.ts`, a window event, so neither surface has to own the other's state. The appearance controls keep their `appearance-*` test ids.

Theme mode is separate again: `apps/canvas/src/theme/theme.ts` resolves `light`, `dark` or `system` and toggles the `dark` class plus `data-theme` on the root, so both the class-based and attribute-based CSS see one decision.

## Theme tokens

`apps/canvas/src/styles/global.css` defines the token set for both themes at the top of the file.

| Token | Role |
| --- | --- |
| `--surface`, `--surface-strong`, `--surface-translucent` | Panel and card backgrounds |
| `--hairline` | Every border, at 8 percent ink in light and 10 percent in dark |
| `--ink`, `--ink-muted` | Body and secondary text |
| `--canvas-bg`, `--rf-dot` | Canvas ground and dot grid |
| `--accent`, `--accent-strong`, `--danger`, `--success`, `--warning` | Named roles, never a raw hue at a call site |
| `--value-*-hue`, `--value-*-fill`, `--value-*-ink` | The eight value roles of the mode and purpose controls |

Muted text carries a measured floor: `--ink-muted` is `#68686d` in light, which is 4.57:1 on the darkest muted surface it appears on, and `#a8a8ad` in dark, which is 5.29:1 on a hovered surface. The value palette deepens a Section hue when it is used as a chip fill, because `--section-blue` at 3.68:1 was never asked to carry text. A card whose paint the resolver had to correct reports `data-contrast-corrected="true"` instead of drawing an illegible pair (ADR-0046).

## Density and canvas treatment

The reader's density setting changes one rule: `[data-density="compact"] .node-card__body` reduces the body padding so more cards fit on one screen. The canvas treatment setting hides the dot grid with `[data-canvas="plain"] .react-flow__background` and moves nothing else. Card radius is a single variable, `--reader-card-radius`, and a card's own stored radius still wins.

`BootSkeleton` covers the wait while IndexedDB is read. It is a `status` region with `aria-busy` and a labelled progress bar, and it deliberately mounts neither the canvas nor any editor.

## The command palette

`apps/canvas/src/command/commandRegistry.ts` describes every user-invokable action once: id, group (`navigation`, `create`, `edit`, `layout`, `view`, `system`), the surface that lists it (`palette`, `library`, `inspector`, `canvas`), the capabilities it needs and a predicate over the current state.

`paletteProviders.ts` projects those definitions into rows, `paletteRank.ts` ranks them, and the palette draws the first 40. The palette holds only its query and its highlighted row, so closing it leaves nothing behind.

Keys: typing filters, `ArrowDown` and `ArrowUp` move the highlight, `Enter` runs the row after closing, `Escape` closes. The search input is a `combobox` with `aria-activedescendant` pointing at the highlighted `option`, so a screen reader announces the row that Enter would run. A command that declares a `shortcut` prints it in a column at the row's right edge, from the same field the shortcut sheet reads.

The library, the inspector and the canvas context menu read the same registry. Availability is a property of the command, which is why a read-only session loses a command everywhere at once rather than in the surfaces someone remembered to guard.

`help.shortcuts` opens the shortcut sheet and `system.settings` opens Settings, both through the window events their hosts listen for. `edit.undo` and `edit.redo` are listed only while `CommandActions` holds a handler. The shell supplies both from `apps/canvas/src/app/useBoardPaletteActions.ts`, which steps the document's undo manager and clears the selection afterwards, exactly as the dock's pair of buttons does.

Three commands act on the document rather than on a surface, and each is listed only while its `CommandActions` field is supplied, so the palette and the dock never offer a row that cannot run. `layout.arrangeRadial` runs the shell's arrangement, which reads the selection in paint order and hands `planRadial` and `childrenFromLinks` (the same pure functions `arrangeRadialSelection` in `apps/canvas/src/canvas/board/useBoardCommands.ts` uses for the board's own menu) to `applyLayoutPlan`. `type.installBlueprint` opens `apps/canvas/src/app/BlueprintInstallHost.tsx` on the card the row names: the palette previews the type the card declares and installs it on one press through `installBlueprintCard`, the same call `installBlueprintSelection` makes. It needs `canManageSchema`, which the registry reads from the same capability matrix as every other write.

## Focus and keyboard

Escape is a stack. `apps/canvas/src/shell/escapeLayers.ts` keeps one layer per open surface, the most recent wins, and the key is claimed in the capture phase so the canvas chain never also acts on it. A text field inside a non-modal surface keeps Escape for itself; a field inside a dialog does not, because a dialog is what Escape is for.

A modal panel traps Tab. `useFocusTrap` in `apps/canvas/src/dialogs/useFocusTrap.ts` moves focus in, wraps Tab and Shift-Tab inside the panel, and returns focus to whatever opened it. It exists because `aria-modal="true"` says the page behind is inert, and before it the last Tab walked into the canvas behind the scrim.

Menus answer the arrows. Every `role="menu"` container handles `ArrowUp`, `ArrowDown`, `Home` and `End` through `menuKeyDown` in `apps/canvas/src/dialogs/menuKeys.ts`, moving focus among its own items and skipping a nested submenu's rows. Without it the role announced behaviour the keys did not have.

A menu also takes focus when it opens. `focusFirstMenuItem` is the menu's `ref`, so the first item holds focus on the frame the menu appears, which is what lets the key handler run at all; before it, focus stayed on the trigger and `ContextMenu`, portalled to `body`, sent Tab to the end of the document. The same ref restores focus to the opener when the menu unmounts, unless something else already took it.

A combobox is not a menu. `apps/canvas/src/cards/CardPicker.tsx` keeps focus in its search field and names the highlighted row with `aria-activedescendant`, because focus moving into the list would end the query after the first keystroke. `focusFirstMenuItem` looks for `menuitem` rows, so it is not the helper that surface needs; `Home` and `End` stay caret keys there for the same reason.

A tablist is one tab stop. `tabKeyDown` in `apps/canvas/src/dialogs/tabKeys.ts` handles `ArrowRight`, `ArrowLeft`, `Home` and `End`, and selection follows focus; `LibraryHeader` and `ContainerPanel` use it, and `CardPanelTabs` follows the same pattern.

The shortcut sheet is the reference for every key. `apps/canvas/src/command/shortcutSheet.ts` builds its rows from the registry's `shortcut` fields and adds the board keys the registry does not own (`[` for the outline, Enter, Delete, Shift+Delete, Escape). `ShortcutSheetHost` opens it on the `suite:shortcuts-open` event or on `?` outside a text field, and the dock's `?` button is the visible trigger.

Undo and redo are a pair of buttons in the dock (`apps/canvas/src/chrome/UndoRedo.tsx`). They read the document's own `canUndo` and `canRedo`, subscribe through `useWorkspaceSnapshot`, and clear the selection after a step, because the card that just went away must not stay selected.

Reveals are a request, not a scroll. A surface that wants a card or a section shown writes `focusTarget` (`focusCard`, `focusSection`), and the canvas consumes it once. No panel calls `setCenter` or `fitBounds`.

Global shortcuts: `Mod+K` opens the palette, `Mod+Shift+K` opens the library on its cards tab and focuses the search box, `Mod+Enter` creates a card at the centre of the viewport, `Mod+G` creates a section around the selection, `Mod+Z` and `Mod+Shift+Z` undo and redo structure, `[` toggles the outline, and `?` opens the shortcut sheet. `Enter` opens the selected node from the canvas keyboard chain, and a title commits on `Enter` rather than adding a line.

## Accessibility

Dialogs and the palette carry `role="dialog"` with `aria-modal="true"` and a label, and every one of them traps Tab. The library drawer is a labelled dialog without `aria-modal`, because it docks beside the canvas and the canvas stays usable.

Focus is drawn one way. `--accent` is the ring colour for every `:focus-visible` rule in `apps/canvas/src/styles/`, at 2px with a 1px offset. The app bar, the board tabs, the dock buttons, the library tabs and the library rows had no rule of their own and fell back to the browser's ring while the rest of the app used three different colours.

State is never colour alone. The status cursor carries a glyph per purpose and names its owner and purpose in a label; sync status pairs a coloured dot with a word; the value controls use shapes and glyphs as well as the role hues.

Motion is a preference before it is a style. `prefers-reduced-motion` removes the launcher slide, the peer cursor and name-tag entrances, the portal scrim and its own transitions, the tour spotlight travel and the live drag transitions. The reader's `data-motion="reduced"` attribute does the same for every element, including transitions a component declared for itself.

Focus is always visible on interactive elements, and a focusable read-only control is avoided: a card title opens on double-click, and the same button also answers `Enter` and `Space`, so the gesture has a keyboard path.

## Tests

| Behaviour | Test |
| --- | --- |
| Focus trap and focus restore | `apps/canvas/src/dialogs/useFocusTrap.test.tsx` |
| Menu arrow keys, and focus entering the menu | `apps/canvas/src/dialogs/menuKeys.test.tsx` |
| Tab strip keyboard | `apps/canvas/src/dialogs/tabKeys.test.tsx` |
| Each menu takes focus on open | `ContextMenu.test.tsx`, `Dock.test.tsx`, `ViewsBar.test.tsx`, `BoardTabs.test.tsx`, `WorkspaceActions.test.tsx`, `ThemeToggle.test.tsx`, `CardTypePicker.test.tsx` and `SignInEntry.test.tsx` beside each component |
| A combobox keeps focus in its field | `apps/canvas/src/cards/CardPicker.test.tsx` |
| Each modal dialog traps Tab | `SignInDialog.test.tsx` and `AdmissionPanel.test.tsx` in `apps/canvas/src/auth/`, `ShareDialog.trap.test.tsx` and `ConflictDialog.test.tsx` in `apps/canvas/src/sharing/`, `SettingsDialog.test.tsx` |
| Card focus mode as a dialog | `apps/canvas/src/cards/CardFocusSurface.test.tsx` |
| Library rows by keyboard, sort and truncation | `apps/canvas/src/library/LibraryDrawer.test.tsx` |
| Shortcut rows and the sheet | `apps/canvas/src/command/ShortcutSheet.test.tsx` |
| Undo and redo buttons | `apps/canvas/src/chrome/UndoRedo.test.tsx` |
| One focus ring token | `apps/canvas/src/styles/focusRing.test.ts` |
| Questions that replaced the platform confirm | `useLibraryRowActions.test.ts` and `LibraryDrawer.test.tsx` in `apps/canvas/src/library/`, `ShareDialog.test.tsx` and `ConflictDialog.test.tsx` in `apps/canvas/src/sharing/` |
| Palette ranking and rows | `apps/canvas/src/command/CommandPalette.test.tsx` |
| Command availability | `apps/canvas/src/command/commandRegistry.test.ts` |
| Tab strip keyboard | `apps/canvas/src/cards/CardPanel.test.tsx` |
| Library filters and selection | `apps/canvas/src/library/*.test.ts` |
| Theme and appearance resolution | `apps/canvas/src/theme/*.test.ts` |
| Escape layer order | `apps/canvas/src/shell/escapeLayers.test.ts` |
| Hover-peek timing and dismissal | `apps/canvas/src/canvas/peek/usePeek.test.ts` |
| Peek resolution per entity kind | `apps/canvas/src/canvas/peek/peekResolver.test.ts` |
| Outline peek wiring | `apps/canvas/src/canvas/peek/outlinePeek.test.tsx` |
| Annotation range states | `apps/canvas/src/canvas/annotations/annotationRecord.test.ts` |
| Blueprint install diff and partial install | `apps/canvas/src/canvas/blueprints/blueprint.test.ts` |
| Error codes and the catalogue | `apps/canvas/src/i18n/i18n.test.ts` |

Storybook covers the components whose look is the behaviour: `*.stories.tsx` beside each component in `apps/canvas/src/chrome/`, `apps/canvas/src/library/`, `apps/canvas/src/presence/` and `apps/canvas/src/dialogs/`.

The surfaces that mount without a live document have a story: `WorkspaceLauncher.stories.tsx` (empty state, because the recents come from this browser), `CommandPalette.stories.tsx` (static rows, including the shortcut column), `ShortcutSheet.stories.tsx`, `SettingsDialog.stories.tsx`, `ConflictDialog.stories.tsx` in `apps/canvas/src/sharing/`, and `ContainerPanel.stories.tsx` in `apps/canvas/src/panels/` with a plain document fixture.

Three surfaces cannot be mounted that way, so the story suite cannot catch a regression in them: the canvas (`apps/canvas/src/canvas/**`) needs the projection, the camera and a real `LoroDoc` fed by the provider; the shell (`apps/canvas/src/app/WorkspaceShellView.tsx`) needs the same document plus a live session; and the share dialogs that call the relay need a `/api/shares` answer, which is why only the pure `ConflictDialog` has a story.
