# 10 · Interaction & Feedback > **Status:** interaction contract. Where [09 · Visual Design](09-visual-design.md) 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](../spec/10-non-functional.md) > 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. --- ## 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). - **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. ## 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](04-routing-and-events.md). - **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 — 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 **0–100 scale** (0 = pane at its minimum, 100 = at its maximum), with `aria-valuemin=0`, `aria-valuemax=100`, and `aria-valuetext="%"` for a clean announcement. The 0–100 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 is deferred to M6** with the pane-visibility / toggle strip — collapsing needs a hidden-pane state that doesn't exist yet, so we don't fake it. _(Consulted via `/council` → WAI-ARIA APG `windowsplitter`. This bullet is the contract; cite it, not the APG file.)_ **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 `