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

279 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).
- **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).
- **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](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 **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
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 `<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.** The library row flags only the _exceptional_
state: a single **accent dot** when a snippet has unpublished draft changes; 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 — 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, and
later Settings/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 — 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.
## 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).
---
## 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).