# 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. **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/snippet-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 — 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 ``. The rule (council: Carbon _Actionable notification_ + APG _Alert_): **announce the change politely and move focus to a stable neighbour.** Concretely, the builder writes "Applied: `