12 KiB
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.
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 (
LivePreviewtreats 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.
- One global router owns document-level
keydown/paste; components never attach their ownwindowlisteners. - 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 isalertdialog, a resize handle iswindowsplitter, a toast region usesalert/statusroles 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-valuenowon a 0–100 scale (0 = pane at its minimum, 100 = at its maximum), witharia-valuemin=0,aria-valuemax=100, andaria-valuetext="<n>%"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 puresideWidthValue()inPanesStore(so it is unit-tested, not trapped in the component); the live value needs the container width, observed viaResizeObserverso it tracks window resizes, not just drags.aria-controlspoints 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.)
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; Carbon status pattern). Pair it with a label,
icon, shape, or text — a red toast border also has a title and an
alertrole; the draft dot has atitle/aria-label. - Every control is labelled. Icon-only buttons, toggles, and fields carry accessible names so assistive tech can announce them.
- 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.
- Consult
/councilwhen 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
windowlisteners, or gate Escape behind the typing check.