mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
211 lines
14 KiB
Markdown
211 lines
14 KiB
Markdown
# 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="<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 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.)_
|
||
|
||
**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 — 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.
|
||
|
||
## 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 `alert` role; 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.
|
||
- 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.
|