Files
astrolabe/docs/architecture/10-interaction-and-feedback.md
T

39 KiB
Raw Blame History

10 · Interaction & Feedback

Status: interaction contract. Where 09 · Visual Design is the visual contract (how the app looks), this is the interaction contract (how the app behaves while the user works in it). It is the HOW for the cross-cutting, non-feature-specific behavior that spec §10 Non-Functional mandates as the WHAT.

These rules already live, scattered, as one-off comments across the codebase (the confirm-dialog's "transactional-modal rule," the toaster's "non-blocking outcomes are toasts," the preview's "empty is not an error"). This document consolidates them into one referenceable contract so a new feature checks the rule instead of re-deriving it — and diverges only on purpose.

Upstream sources. These principles are drawn from the design council (/council): IBM Carbon, the GOV.UK Design System, the WAI-ARIA Authoring Practices Guide, and Nielsen Norman Group. The council advises; this contract decides. When you face a new interaction decision this doc doesn't cover, consult the council, then record the resolution back here.

Cite the spec for behavior; own the how. Each Resolved — bullet below is the contract for an interaction's mechanics — the ARIA role, the keyboard model, the focus move — and records why (the council citation). It is not the source for product behavior: what a surface shows, when it appears, what the data does. That lives in docs/spec/; a bullet names the behavior in a clause, cites (spec §NN), and never restates or overrides it. On any disagreement, the spec wins and the bullet is the bug (see 00-overview → "cite, don't restate").


1. The feedback-channel decision table

Astrolabe has four distinct ways to tell the user something. They are not interchangeable; picking the wrong one is the most common interaction bug. Choose by the nature of the message, not by convenience.

Channel Use when Blocks? Dismissal Implemented by
Confirm dialog A destructive or irreversible action needs explicit consent (delete, revert, reset) Yes — modal User must choose; Escape/Cancel = no; backdrop click does not dismiss ConfirmStore + ConfirmDialog
Toast A non-blocking outcome happened the user should know about (save failed, published, imported) No Auto for success/info; persists for error/warning; always a close button NotificationStore + Toaster
Inline error A problem is tied to a specific surface and recovers in place (invalid spec → editor + preview) No Clears automatically when the cause is fixed PreviewStore, surfaced in SpecEditor + LivePreview
Status indicator Passive, ambient state worth glancing at (draft vs. published, storage usage) No N/A — it just reflects state library draft dot; storage monitor (later)

Rules.

  • One blocking question at a time. Confirm dialogs and feature modals are mutually exclusive (the modal coordinator enforces this); a confirm may layer over a modal (discard-changes prompt), nothing else stacks.
  • Match disruptiveness to urgency (Carbon). A toast interrupts less than a dialog; don't use a dialog for something a toast can carry, and don't bury a consent-for-destruction in a toast.
  • Errors persist; success fades. An error/warning toast waits for the user (a critical message must not vanish on a timer); success/info auto-dismiss (~6s).
  • Toasts sit bottom-right, not top-right. Carbon's default is the top, but our header's action cluster (Publish/Revert, theme/datasets) lives top-right — a toast there covers the control the user just used. Bottom-anchored, the stack grows upward with the newest toast nearest the corner, and still clears the centered confirm dialog. (Toaster.module.css.)
  • Toast copy: title states it, message adds to it. Every toast renders a title and a message. The title is the short headline — the action or what stopped, no terminal period ("Snippet published", "Storage full"). The message is one short sentence that must not paraphrase the title (Carbon, components/notification/usage.mdx §Body content: "Don't repeat or paraphrase the title"); it carries the consequence for a success ("Your draft is now the published version") or the next step for a fixable error. Name the specific item in the message when toasts can stack — a delete confirms which one went ("…removed \"Sales\"…").
  • Toast only what the user can't already see. A success toast is for an outcome with no strong on-screen cue: a side effect (Extract creates a dataset off-screen while the user is in the editor), a disappearance (delete), or a state flip (publish/revert). An action whose result is immediately visible — a created snippet opening in the editor, a new dataset shown selected — is confirmed by that visible change; adding a toast is noise (NN/g aesthetic-and-minimalist; Carbon notification/usage.mdx "Deciding what to use"). An invisible outcome that still shouldn't toast is copy-to-clipboard: confirm it inline on the control ("Copied") with a polite aria-live announcement for assistive tech, never a toast-per-copy. This refines the spec's earlier blanket "every action toasts" (spec §01F/§02/§05, reconciled).
  • An action's confirmation belongs with the action, not its call site. When an action is reachable from more than one trigger (e.g. publish is both a toolbar button and Cmd/Ctrl+S), pair the store mutation and its toast in one services/ helper (publishActiveSnippet) that every trigger calls — otherwise the toast rides one path and the other publishes silently (the exact inconsistency this rule prevents). The shortcut is owned globally by the EventRouter (arch 04), so the helper is the only place the outcome is confirmed.
  • The same failure can light up two channels. An unrenderable spec shows the same message inline in both the editor (§03E) and the preview (§04) — one producer (PreviewStore), two subscribers. That's intentional, not duplication.

2. Latency & feedback budgets

From NN/g's response-time limits (reference/principles/nielsen-norman.md). These are not aspirations — they're the basis of the render pipeline's shape.

Budget Feels like Owed feedback Astrolabe surfaces
≤ 0.1s Instantaneous None beyond showing the result Keystrokes into the buffer, hovers, toggles, selection
≤ 1s Uninterrupted thought None needed, but direct-manipulation feel is lost A typical render after the debounce; opening a modal; switching snippets
> 1s Noticeably waiting Must not block input; show a busy indication A heavy spec / large-dataset render
> 10s Attention lost Progress indicator + stay cancellable; let the user work elsewhere (guard for large M3+ dataset work)

Rules.

  • Input is sacred. Typing and navigation never block on rendering or persistence (spec §10). The render pipeline is debounced (RENDER_DEBOUNCE_MS), runs async, and uses a generation token so a slow render can't overwrite a newer one.
  • Auto-save is cheap and silent (AUTOSAVE_DEBOUNCE_MS) — it never stalls typing and produces no toast on success (only on failure).
  • A busy indication may overlay the preview but must not freeze the editor (spec §10). When a render might exceed ~1s, owe a non-blocking indicator; never a frozen UI.

3. The non-happy-path triad

Every surface that shows data owes three designed states, not one. The empty and error states are part of the feature, not an afterthought (Carbon empty-states, GOV.UK).

  • Loading — when data isn't ready yet. Prefer a skeleton/placeholder over a spinner for structural loads; show it only for a beat.
  • Empty — when there is legitimately nothing. Empty ≠ error: a blank editor renders a clean, calm empty preview, never an error (LivePreview treats blank as success). An empty list says what would be here and how to add it.
  • Error — when something went wrong. Split by who can fix it:
    • User-fixable → state the consequence and the next step ("Storage full — delete snippets to free space, then edit again"). A user action is mandatory (Carbon/GOV.UK).
    • Not user-fixable → explain plainly and attach a reportable diagnostic (the operation + underlying error) so it can be traced. See services/storage-errors.ts — this split is the module's whole reason for being.
  • Wording (NN/g #9, GOV.UK, Carbon content): plain language, no error codes in the user-facing line, second person, name what stopped in the title, one or two sentences in the body, never flippant.
  • Status carries a glyph, not only colour (arch 09 §5.2 status set; Carbon notification taxonomy). Error/warning/success/info surfaces (toasts, inline warnings) lead with the filled status icon, coloured by severity — a redundant non-colour channel so severity reads under colour-blindness (WCAG 1.4.1), with the triangle shape-coding warning apart from the round error/success/info. Colour + icon + title together; never colour alone.

4. Recovery & data-safety contract

The user must never silently lose work, and must always have a way back (NN/g #3 "user control and freedom," #5 "error prevention"; spec §10 Reliability).

  • No silent data loss. Edits auto-save as a draft; a known-good published version is always preserved separately (§03D). A failed persist surfaces as a toast, never a swallowed promise (orchestration/persistence.ts).
  • A marked exit from every committed change. Revert restores the published spec; Escape closes modals; delete/revert/reset require confirmation first.
  • Resilient rendering recovers on its own. An invalid spec shows a readable error and auto-recovers when fixed — it never leaves the app wedged (§04).
  • Non-destructive import. Import merges, never overwrites; on failure the existing workspace is left exactly as it was (§08).

5. Keyboard & focus contract

Astrolabe is keyboard-operable end to end (spec §10 Accessibility). The interaction patterns follow WAI-ARIA APG (reference/aria-practices/content/patterns/); the wiring lives in 04 · Routing & Global Events.

  • One global router owns document-level keydown/paste; components never attach their own window listeners.
  • Escape is an ordered priority chain that returns on first consumption (blocking message → modal → menu → selection) and is checked before the interactive-context gate, so it dismisses a modal even while the editor has focus. All other shortcuts are gated by isInInteractiveContext() so they never fire mid-typing.
  • Shortcuts claim their key with preventDefault() so they override the browser default (⌘S doesn't "save page," ⌘K doesn't focus the URL bar).
  • Focus moves into an overlay on open and returns to the trigger on close; focus is trapped within it (useFocusTrap). Destructive confirms focus Cancel first.
  • Match the APG pattern for any new interactive widget — a modal is dialog-modal, a destructive confirm is alertdialog, a resize handle is windowsplitter, a toast region uses alert/status roles by severity. Don't invent keyboard models; adopt the documented one.

Resolved — a control that removes its own container. When activating a control deletes the element it lives in (e.g. a Chart Builder guidance hint's one-click fix button — the hint re-derives away once applied), focus must not fall to <body>. The rule (council: Carbon Actionable notification + APG Alert): announce the change politely and move focus to a stable neighbour. Concretely, the builder writes "Applied: <label>." to a visually-hidden role="status" aria-live="polite" node and moves focus to the guidance region if any hints remain, else the surrounding pane (tabIndex={-1} anchors, focused only programmatically — no visible ring). Advisory hints themselves don't grab focus (APG: an alert "must not affect keyboard focus"); the fix's remedy lives in a low-emphasis ghost button beside the advice (Carbon: inline actionable → ghost button, wraps under the body on narrow widths), an offer, never a forced change.

Resolved — pane resize handle (window splitter). A ResizeHandle is a focusable role="separator" that reports the controlled pane's size, per APG → Window Splitter:

  • aria-valuenow on a 0100 scale (0 = pane at its minimum, 100 = at its maximum), with aria-valuemin=0, aria-valuemax=100, and aria-valuetext="<n>%" for a clean announcement. The 0100 normalization (APG's "typical") beats raw pixels: it's stable and announces as a percentage. The math is the pure sideWidthValue() in PanesStore (so it is unit-tested, not trapped in the component); the live value needs the container width, observed via ResizeObserver so it tracks window resizes, not just drags.
  • aria-controls points at the pane it sizes (pane-library / pane-preview).
  • Keyboard: ←/→ nudge; Home → smallest pane size, End → largest. Enter-to-collapse now has a home — the hidden-pane state landed in M6 with the toggle strip (below). The strip owns show/hide; wiring the splitter's Enter to it is an optional convenience, not required.

Editor-hidden layout. Pane model: the two side panes carry stored widths; the editor is the flex filler (no stored width while shown). Show/hide is layout-aware only for the editor — hiding captures the width it had (capturedEditorWidth), showing pins it back and re-splits the rest at the side panes' ratio (shownSideWidths); side panes just flip visibility and flex-fill the freed space (spec §01A). So togglePane needs panesInner (panes-row width minus the toggle strip, measured by PaneToggleStrip) for the editor only. With the editor hidden, library and preview become adjacent and share a second splitter, PaneSplitHandle — separate from ResizeHandle because the geometry differs: ResizeHandle resizes one side pane while the editor absorbs the change (the opposite side is untouched); PaneSplitHandle re-proportions two panes with no filler between them (editor-hidden, the side panes render flex: width 1 0 and share the span), the pure splitLibraryWidth() holding each above its min. valuenow trade-off: it reports the library's 0100 position (splitValue()) from the stored ratio, deliberately container-independent — so unlike ResizeHandle it does not observe the container, and aria-valuenow can drift from the rendered position after a window enlargement (worst near the extremes). Drag/keyboard read live clientWidth, so resizing itself stays accurate; only the announced value drifts.

(Consulted via /council → WAI-ARIA APG windowsplitter. This bullet is the contract; cite it, not the APG file.)

Resolved — pane toggle strip. The persistent show/hide strip (spec §01A) is a WAI-ARIA APG toolbar (role="toolbar", aria-orientation="vertical", an aria-label such as "Workspace panes") — not a row of independently-tabbable buttons. Grouping into a toolbar gives the cluster a single tab stop with a roving tabindex, which APG names as the way to reduce tab stops for a control group. Vertical keyboard model: Up/Down move among controls, Home/End jump to first/last, Tab/Shift+Tab move into/out and restore the last-focused control on re-entry.

  • The three pane controls are toggle buttonsaria-pressed with a stable accessible name that does not change with state (aria-pressed="true" ⇔ pane visible; the name stays "Library pane" / "Editor pane" / "Preview pane"; only the icon may swap). This matches the ThemeToggle precedent and APG's toggle-button rule — "it is critical the label on a toggle does not change when its state changes." These are independent booleans, so toggle buttons — never a radio/segmented group; reserve role="switch" for genuine single-setting on/off.
  • The Datasets control is a plain command button (no aria-pressed) in the same toolbar — APG permits mixed control types — set off from the toggles by a visual divider (and optionally a nested role="group"), but kept in the roving sequence as its last element.
  • Focus: show/hide is only ever initiated from the strip, so the activating toggle already holds focus when its pane disappears and retains it (the button stays, flips to not-pressed) — no orphaned focus, no restoration logic. The strip is never itself hidden, so even with all panes hidden it stays the always-reachable "emergency exit" (NN/g #3 user control). The pane appearing/disappearing plus the aria-pressed flip is the status feedback (NN/g #1 visibility of system status).

(Consulted via /council → WAI-ARIA APG toolbar + button (toggle); NN/g #1/#3. This bullet is the contract; cite it, not the APG files.)

Resolved — segmented (single-select) controls. A "pick one of N" control (fit modes, the Draft/Published view) is a radio group, never a row of aria-pressed toggles (those model N independent booleans). Use the shared SegmentedControl: role="radiogroup"

  • role="radio"/aria-checked, a roving tabindex (only the selected option is a tab stop), and Arrow/Home/End to move-and-select (APG → Radio Group). One widget so the keyboard model is defined once. (Tabs were a candidate for Draft/Published; we chose radio group for consistency with the other segmented controls and to avoid tabpanel wiring to Monaco. A toggle switch was also weighed and rejected: APG defines role="switch" as on/off of a single setting, but Draft/Published selects between two named peer views with no natural "on" side — a radio group is the right semantics. Reserve the switch for genuine on/off settings. Consulted via /council → APG switch / radio-group / tabs.)

Resolved — selectable lists. A row the user selects must be a real <button> (or a proper option), not a click handler on <li> (mouse-only, no keyboard, no role). It is not an APG listbox when a row contains its own controls (e.g. a delete button) — APG forbids interactive children in a listbox. Mark the active row with aria-current="true" only on that row (don't emit aria-current="false" everywhere). Arrow-key roving between rows is a later enhancement; button-per-row tab stops are the acceptable baseline.

Resolved — snippet-list status indicator. Spec §02 owns the behavior — the row distinguishes a snippet with unpublished draft changes from a fully-published one; this bullet owns the how. The row flags only the exceptional state: a single accent dot for the unpublished case; a fully-published snippet shows no dot (presence = draft, absence = published). We do not give "published" its own glyph — GOV.UK's Tag guidance notes one status suffices when absence is self-evident, and Carbon's status-indicator pattern says not to highlight what isn't significant. Meaning never rests on hue (WCAG 1.4.1): it rides on presence/absence plus the dot's accessible label, and the colour is the neutral accent — not a warning hue, because unpublished work is a normal state, not a problem. The dot sits in the row's secondary metadata line (with the relative date and size), in a fixed-width leading slot so it never shifts adjacent text. (Consulted via /council → GOV.UK Tag, Carbon status-indicator-pattern, APG. This bullet is the contract; cite it, not the external source.)

Resolved — storage composition indicator. The library-footer Storage Monitor (spec §02) shows what storage is made of — Snippets · Datasets · App — as a proportional bar plus a labelled legend, not a "used of quota" gauge. The browser quota is a padded, unreliable approximation (web.dev → storage-for-the-web), so a budget fraction is false precision; we show real measured sizes instead.

  • Not a meter. No role="meter"/progressbar: a meter needs a meaningful maximum, and a composition with no trustworthy ceiling has none (APG → meter: "should not be used to represent a value … [without] a meaningful maximum"). The visual bar is decorative (aria-hidden); the legend's text labels + sizes are the accessible source of truth, so meaning never rests on hue (WCAG 1.4.1).
  • Part-to-whole in a tiny space. A single proportional stacked bar suits a few segments (we have three) — FT Visual Vocabulary (Part-to-whole) + Datawrapper (stacked bar for "a few shares"; bar "when precise reading matters") — paired with absolute byte labels for the precise read.
  • Unavailable degrades, not disappears. Snippets + datasets are measured from our own data, so they always show; only the App segment (which needs the origin estimate) drops out when the Storage Manager API is absent.
  • No proactive "almost full" warning. Dropping the old 0.8/0.95 thresholds is intentional — they keyed off the untrustworthy quota, and a fake fuel gauge fails NN/g #1 (visibility of system status) more than it serves it. The genuine out-of-room event surfaces at save time as an actionable error (services/storage-errors.ts → recover by deleting), satisfying NN/g #9.

(Consulted via /council → WAI-ARIA APG meter, FT Visual Vocabulary + Datawrapper (part-to-whole), web.dev storage, NN/g #1/#9, WCAG 1.4.1. This bullet is the contract; cite it, not the sources.)

Resolved — library search (Carbon active-search). The snippet-library search (spec §02) is an unlabelled active-search input pinned above the list: type="search" with a leading magnifier and aria-label="Search snippets" (no visible label — the icon + placeholder name it), filtering the list on each keystroke (no Search button, no results page). A clear (✕) appears only when the box is non-empty; it empties the box and returns focus to the input (NN/g #3 user control). Matching is case-insensitive substring across name + comment + draft spec text (the pure snippetMatchesQuery in core/snippet-sort.ts). Search affects visibility only — it never changes activeSnippetId or any data. A polite aria-live region announces the result count, including no results (Carbon: "always include the number of results, including no results"); it stays silent for the default unfiltered list. (Consulted via /council → Carbon search/active-search, NN/g #3. This bullet is the contract; cite it, not the source.)

Resolved — library sort (APG menu-button + NN/g recognition). The Sort control (spec §02) reuses the disclosure popover primitive (the settings-popover model above), not an ARIA menu — but its trigger shows the current state for recognition ("Modified ↓"), per NN/g #6 (recognition over recall), instead of a bare gear. (The visible text drops the "Sort:" verb prefix to stay compact in the narrow library rail — §8; the full name "Sort by Modified, descending" rides in aria-label.) Fields are Modified / Created / Name / Size; the active field shows a direction arrow (↓ desc / ↑ asc) in both the trigger and the field row, and the arrow's meaning is mirrored into the field's aria-label ("Modified, descending") so it isn't carried by the glyph alone. Selection model (spec §02): re-selecting the active field flips direction; selecting a different field switches to it and resets to descending — encapsulated in SnippetStore.setSort and tested in core. APG disclosure keyboard/focus is inherited from the shared popover (Enter/Space open; Esc closes and returns focus to the trigger; outside click closes; one open at a time). Field+direction persist across sessions (ux-prefs; default Modified/desc); search is not persisted (it's a transient view narrowing). (Consulted via /council → WAI-ARIA APG disclosure + menu-button, NN/g #6. This bullet is the contract; cite it, not the source.)

Resolved — the empty-library onboarding canvas (and the list's single empty state). The snippet list owes one empty stateno search matches ("No snippets match your search", with a hint to try a different term). There is no separate "empty library" list state, because a genuinely empty library never shows the list at all: rather than seeding a placeholder snippet (the old behavior), the workspace replaces the entire pane chrome — toggle strip, library list, editor, and preview — with a full-width onboarding canvas (spec §02 → First-Run & Empty Workspace): with no snippets, the library controls and pane toggles have nothing to act on. (So the list's own empty copy is reached only mid-search, never on a cold start.) The canvas is a welcome, a primary "Create your first snippet", and a gallery of example snippets; leaving it (creating the first snippet) lays the panes out at a default 25·25·50 split via PanesStore.applyOnboardingSplit so the first chart opens with a generous preview. Each card renders live through the shared chart-renderer (no parallel embed path; each card owns its RenderHandle and finalizes on unmount — the per-card nodes are independent, so they don't touch LivePreview's single-host serialization), and adds as an ordinary snippet. Empty stays calm and positive, never an error (NN/g aesthetic-and-minimalist; §3 empty ≠ error). Council resolutions for the canvas: (1) each card preview is decorativearia-hidden, skipped by screen readers (Carbon empty-states a11y / WCAG decorative images); the card name + one-line description + a uniquely-labelled Add button ("Add Bar chart", APG button) carry the meaning, so AT users reach no dead end. (2) The canvas is the single empty surface — because it replaces the library list outright, there is no competing "No snippets yet" status elsewhere to keep in sync. Its heading owns the app identity ("Welcome to Astrolabe") and it carries the only Create nudge, so the empty-state message lives in exactly one place (Carbon "keep words to a minimum"; no duplication). (3) The primary action dominates (accent "Create your first snippet" first; the example gallery is framed as a secondary "Or start from an example") — Carbon sanctions starter content as an in-depth first-use empty state only when one action stays primary. (4) Domain vocabulary stands: "Vega-Lite", "JSON", "snippet" are kept despite Carbon's avoid-jargon rule, because SOUL #2 (Vega-Lite Native) makes them the user's real language — a deliberate divergence. (Consulted via /council → Carbon empty-state + content, GOV.UK headings, WAI-ARIA APG button, NN/g. This bullet is the contract; cite it, not the source.)

Resolved — one live region per shared message. When the same error feeds two surfaces (the §1 "one producer, two subscribers" case — render errors via PreviewStore), exactly one subscriber is the live region (role="alert" on the editor, where focus is); the other shows the text visually with no live role. Two live regions would announce the same message twice.

Resolved — feature-modal dismissal & initial focus. A feature modal (Datasets, Chart Builder) is a passive dialog-modal: dismissed by the close button, Escape, or a backdrop click (a passive modal carries no in-flight transaction, so an outside click is a safe cancel — unlike the alertdialog confirm, where backdrop-dismiss is forbidden). role="dialog" + aria-modal + aria-labelledby the title; focus is trapped and returns to the trigger on close. Backdrop-dismiss stays correct even for the multi-view Datasets manager because an in-progress create/edit form is guarded separately by the discard prompt. Initial focus depends on size (APG dialog-modal): a large manager with semantic content (list + detail) focuses a static title (tabindex="-1") so the content's start is perceived rather than skipped to the first control; a small form modal (Extract) focuses its primary field. (Consulted via /council → WAI-ARIA APG dialog-modal. This bullet is the contract; cite it, not the APG file.)

Resolved — settings are distributed, not a modal; each cluster is a disclosure popover. Preferences (spec §07) live next to what they affect and apply live: theme is the header toggle, editor settings open from the editor toolbar, render debounce from the preview, date format from the library. This matches the already-distributed theme + fit-mode controls, makes a change's effect visible in the pane being configured, and keeps each block independently extensible — so there is no central Settings modal and no Apply/Cancel/dirty commit step (changes are individually reversible; the editor cluster offers a Reset). The disclosure mechanism is a gear button + non-modal popover, not an ARIA menu: a menu lists actions/commands (menuitem/menuitemcheckbox/menuitemradio), but these panels hold sliders, number/text inputs, and radio groups, so the container is a labelled group. The gear carries aria-expanded + aria-controls; Enter/Space toggle; Esc closes and returns focus to the gear; an outside click closes; at most one is open at a time; focus moves to the first control on open (so Cmd/Ctrl+,, which opens the editor cluster, lands inside it). Non-modal — no focus trap (unlike the feature modal above). The panel is portaled to <body> and positioned fixed because the panes clip their content. (Consulted via /council → NN/g #4 consistency, #6 recognition-over-recall, #8 minimalist; WAI-ARIA APG disclosure + menu-and-menubar; Carbon popover/overflow-menu/text-toolbar. This bullet is the contract.)

Resolved — an error names the right fix, not a boilerplate one. Don't staple a generic remedy onto every failure. A missing dataset reference is not a JSON/spec syntax problem, so the preview gives it a tailored, fixable line — "Dataset «X» not found. Create it from Datasets (⌘/Ctrl+K), or check the dataset name in your spec." — instead of the catch-all "check your JSON syntax" hint reserved for actual parse/Vega-Lite errors. The fix in the copy must match the actual cause (NN/g #9, GOV.UK error-message). The thrown DatasetNotFoundError carries datasetName so the surface can name it.

Resolved — service-worker update prompt & persistent storage (web.dev seat). The build uses registerType: 'prompt', so a new service worker waits and never takes over a running session on its own — the app must tell the user, or "ask before updating" silently means "never update." orchestration/pwa.ts consumes virtual:pwa-register and surfaces onNeedRefresh as a durable (non-auto-dismissing) info toast with a Reload action that calls updateSW(); onOfflineReady is a transient success toast. Separately, browser storage is best-effort and evictable under pressure, which for a local-first workspace is data loss — so we request navigator.storage.persist() once at startup (infrastructure/storage-persist), feature-detected and silent on denial (Chromium decides automatically; nothing for the user to act on). (Consulted via /council → web.dev, with the exact API from vite-plugin-pwa/Workbox; see reference/principles/web-dev.md. This bullet is the contract; cite it, not the source.) Known gap: the manifest ships no icons, so the app is not yet installable — a design-asset task, logged not passed.

6. Motion & accessibility as default

Not features to add later — the baseline every surface is built on.

  • Reduced motion is honored globally. Animations/transitions are neutralized under prefers-reduced-motion (styles/base.css); never gate meaning on motion.
  • Colour is never the sole signal (WCAG 1.4.1; Carbon status pattern). Pair it with a label, icon, shape, or text — a toast carries a title, an alert/status role, and a filled status glyph coloured by severity (§3); the draft dot has a title/aria-label.
  • Every control is labelled. Icon-only buttons, toggles, and fields carry accessible names so assistive tech can announce them.
  • A binary toggle exposes its state, not just its action. A theme/on-off control is a toggle button (aria-pressed) or switch (aria-checked) with a stable name, so AT announces the current state at parity with the icon a sighted user sees — not just "switch to dark" (APG → Button / Switch). The ThemeToggle uses aria-pressed + a stable label.
  • The shell has a heading and a bypass. The app exposes an <h1> (not a styled <span>) so there's a heading outline, and a skip link as the first focusable element so keyboard users can bypass the header into #main (WCAG 2.4.1 / GOV.UK). Same-type landmarks carry distinct accessible names.
  • Contrast holds in every theme. A theme that can't meet legible contrast in part of the UI is not complete (spec §10 / §07).

7. Revealed actions & destructive affordances

How row/list actions appear, and how dangerous ones signal themselves. (Pairs with the iconography contract, arch 09 §5.)

  • Reveal-on-hover is a per-surface choice, not a default. Hiding a control until hover cuts clutter in a dense, repeated list the user inevitably traverses (the snippet-row delete) — there, arrival is guaranteed, so discoverability isn't lost. But a rare or load-bearing action must stay always-visible, or it becomes effectively unreachable (NN/g #6 — recognition over recall; a feature you can't see you can't use). Decide per surface; when in doubt, show it.
  • A hover-revealed control must also reveal on keyboard focus. Gate visibility on :hover and :focus-within/:focus-visible, never hover alone — otherwise the action is mouse-only and invisible to keyboard users (WCAG 2.1.1). The snippet row reveals its delete on .item:hover and .delete:focus-visible.
  • Destructive controls signal danger on hover and focus. A delete/remove affordance reddens to --support-error on both :hover and :focus-visible — not colour-by-mouse only — so the warning reaches keyboard users at parity. Colour is a reinforcement here, never the sole signal: the control still carries its label/aria-label and the consequential ones still route through a confirm dialog (§4).

8. Space-constrained controls (responsive collapse)

The three work panes resize independently, so a toolbar's room is a function of its pane's width, not the viewport's. Controls must stay usable at the pane minimum without clipping, wrapping, or crowding.

  • Query the pane, not the window. Use a CSS container query (container-type: inline-size on the row, @container on the controls), not a media query — the pane width is what changed. Scope the container to the toolbar row itself, away from heavy children (e.g. the Monaco editor) whose own layout shouldn't inherit size containment.
  • Shed labels under pressure; keep a contested primary labelled. When a toolbar would wrap or crowd, secondary actions collapse to icon-only and the label moves to aria-label/title. A primary that shares the row with secondaries keeps its text — the label is what marks it as the action to take (the editor toolbar below ~480px: Publish stays "Publish"; Extract/Revert become glyphs). A standalone primary CTA, whose prominence is carried by fill + size + position rather than its words, may collapse to a universal-set icon at the pane floor (the library's "Create New Snippet" → "+" below ~250px). Either way it's a degradation that preserves the accessible name — distinct from the closed icon-only set (arch 09 §5.1 rule 4).
  • Trim a compact trigger's prose, not its state. A disclosure trigger that names its current state for recognition (NN/g #6) may drop the verb prefix to fit a narrow rail — the library Sort trigger shows "Modified ↓", not "Sort: Modified ↓" — but the full name (aria-label="Sort by Modified, descending") is preserved for assistive tech, so only redundant visible words are cut.
  • A flex control must be able to shrink. A side control (a Sort button) carries flex: 0 0 auto so the flexible field (search) absorbs the slack; the field's <input> needs min-width: 0, or its intrinsic ~20ch width overflows the slot and overlaps its neighbour. Right-aligned toolbars (justify-content: flex-end) clip their leftmost item on overflow — left-align so the trimmable end is a settings affordance, not a primary control, and size the pane minimum so it doesn't overflow at all.

Do / Don't

Do

  • Pick the feedback channel from §1's table by the nature of the message.
  • Treat loading/empty/error as three designed states for every data surface.
  • Adopt the APG keyboard pattern for new widgets; route all global keys through the one router.
  • Mark optional fields, not required ones (GOV.UK) — e.g. "Comment (optional)".
  • Consult /council when this contract is silent — then record the answer back here.

Don't

  • Don't show a blocking dialog for something a toast can carry, or hide a consent-for-destruction in a toast.
  • Don't let a render or a save block typing.
  • Don't treat "empty" as "error."
  • Don't put error codes in the user-facing line — put diagnostics in the detail disclosure, the next step in the message.
  • Don't invent a keyboard model, attach ad-hoc window listeners, or gate Escape behind the typing check.
  • Don't ship a dead disabled control as a placeholder for an unbuilt feature — a disabled button explains nothing and is skipped by assistive tech (GOV.UK, NN/g). Omit the action until it works, then show it enabled (e.g. "Build Chart" appears with M4).