# 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 `<body>`. The rule (council:
Carbon _Actionable notification_ + APG _Alert_): **announce the change politely and move
focus to a stable neighbour.** Concretely, the builder writes "Applied: `<label>`." to a
visually-hidden `role="status" aria-live="polite"` node and moves focus to the guidance
region if any hints remain, else the surrounding pane (`tabIndex={-1}` anchors, focused only
programmatically — no visible ring). Advisory hints themselves don't _grab_ focus (APG: an
alert "must not affect keyboard focus"); the fix's remedy lives in a **low-emphasis ghost
button** beside the advice (Carbon: inline actionable → ghost button, wraps under the body
on narrow widths), an _offer_, never a forced change.

**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**
  now has a home — the hidden-pane state landed in M6 with the toggle strip (below). The strip
  owns show/hide; wiring the splitter's Enter to it is an optional convenience, not required.

**Editor-hidden layout.** Pane model: the two **side** panes carry stored widths; the **editor
is the flex filler** (no stored width while shown). Show/hide is layout-aware only for the
editor — hiding **captures** the width it had (`capturedEditorWidth`), showing **pins it back**
and re-splits the rest at the side panes' ratio (`shownSideWidths`); side panes just flip
visibility and flex-fill the freed space (spec §01A). So `togglePane` needs `panesInner`
(panes-row width minus the toggle strip, measured by `PaneToggleStrip`) for the editor only.
With the editor hidden, library and preview become adjacent and share a **second splitter**,
`PaneSplitHandle` — separate from `ResizeHandle` because the geometry differs: `ResizeHandle`
resizes one side pane while the editor absorbs the change (the opposite side is untouched);
`PaneSplitHandle` re-proportions **two** panes with no filler between them (editor-hidden, the
side panes render `flex: width 1 0` and share the span), the pure `splitLibraryWidth()` holding
each above its min. **valuenow trade-off:** it reports the library's 0–100 position
(`splitValue()`) from the **stored ratio**, deliberately container-independent — so unlike
`ResizeHandle` it does **not** observe the container, and `aria-valuenow` can drift from the
rendered position after a window enlargement (worst near the extremes). Drag/keyboard read live
`clientWidth`, so resizing itself stays accurate; only the announced value drifts.

**Horizontal variant — the data-inspector divider.** The chart ↔ data-inspector divider in
the Live Preview (`InspectorSplitHandle`, spec §04) is the same window-splitter contract with
the axis flipped: `aria-orientation="horizontal"`, ↑/↓ + Home/End, sizing a stacked region's
**height** instead of a pane's width. It reads its flanking siblings (chart above, inspector
below) like `PaneSplitHandle`, with the gesture in `useRowResizeDrag` (the row twin of
`useColResizeDrag`) and the clamp/value math pure in `AppStore` (`clampInspectorHeight` /
`inspectorHeightValue`). Rendered only while the inspector is open.

_(Consulted via `/council` → WAI-ARIA APG `windowsplitter`. This bullet is the contract;
cite it, not the APG file.)_

**Resolved — pane toggle strip.** The persistent show/hide strip (spec §01A) is a **WAI-ARIA
APG `toolbar`** (`role="toolbar"`, `aria-orientation="vertical"`, an `aria-label` such as
"Workspace panes") — **not** a row of independently-tabbable buttons. Grouping into a toolbar
gives the cluster a **single tab stop** with a **roving tabindex**, which APG names as the way
to reduce tab stops for a control group. Vertical keyboard model: **Up/Down** move among
controls, **Home/End** jump to first/last, **Tab/Shift+Tab** move into/out and restore the
last-focused control on re-entry.

- The three pane controls are **toggle buttons** — `aria-pressed` with a **stable** accessible
  name that does **not** change with state (`aria-pressed="true"` ⇔ pane visible; the name stays
  "Library pane" / "Editor pane" / "Preview pane"; only the icon may swap). This matches the
  `ThemeToggle` precedent and APG's toggle-button rule — _"it is critical the label on a toggle
  does not change when its state changes."_ These are **independent booleans**, so toggle
  buttons — never a radio/segmented group; reserve `role="switch"` for genuine single-setting
  on/off.
- The **Datasets** control is a plain **command button** (no `aria-pressed`) in the _same_
  toolbar — APG permits mixed control types — set off from the toggles by a visual divider (and
  optionally a nested `role="group"`), but kept in the roving sequence as its last element.
- The **pressed (pane-visible) state has its own visual cue**: an inset accent edge bar plus the
  full-strength glyph on the filled chip (the activity-bar convention). A fill alone cannot be
  the state cue — hover uses the same one-step fill, so a fill-only pressed state is
  indistinguishable from hovering an off toggle (the collision class arch 09 names for hover);
  and a bar, not a border, keeps the strip boxless per the arch 09 box-discipline rule. The
  unlabeled glyph rail itself is the established activity-bar convention (NN/g #4) — toggles
  carry tooltips per Carbon's icon-button rule; no visible labels.
- **Focus**: show/hide is only ever initiated **from the strip**, so the activating toggle
  already holds focus when its pane disappears and **retains it** (the button stays, flips to
  not-pressed) — no orphaned focus, no restoration logic. The strip is **never itself hidden**,
  so even with **all panes hidden** it stays the always-reachable "emergency exit" (NN/g #3 user
  control). The pane appearing/disappearing plus the `aria-pressed` flip is the status feedback
  (NN/g #1 visibility of system status).

_(Consulted via `/council` → WAI-ARIA APG `toolbar` + `button` (toggle); NN/g #1/#3. This
bullet is the contract; cite it, not the APG files.)_

**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. _(Not a toggle switch: 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.)_ A per-option
  `title` (tooltip for a terse label) doubles as the option's accessible name, so it must
  **lead with the visible label** ("Original — the natural size from the spec"), or
  speech-input users can't address the control they see (WCAG 2.5.3 label-in-name).

**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.** Spec §02 owns the behavior — the row
distinguishes a snippet with **unpublished draft changes** from a **fully-published** one; this
bullet owns the _how_. The row flags only the _exceptional_ state: a single **accent dot** for
the unpublished case; 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 — storage composition indicator.** The library-footer Storage Monitor (spec §02)
shows what storage is **made of** — Snippets · Datasets · App — as a proportional bar plus a
labelled legend, **not** a "used of quota" gauge. The browser quota is a padded, unreliable
approximation (web.dev → _storage-for-the-web_), so a budget fraction is false precision; we
show real measured sizes instead.

- **Not a meter.** No `role="meter"`/`progressbar`: a meter needs a meaningful maximum, and a
  composition with no trustworthy ceiling has none (APG → `meter`: _"should not be used to
  represent a value … [without] a meaningful maximum"_). The visual bar is **decorative**
  (`aria-hidden`); the **legend's text labels + sizes are the accessible source of truth**, so
  meaning never rests on hue (WCAG 1.4.1).
- **Part-to-whole in a tiny space.** A single proportional stacked bar suits a **few** segments
  (we have three) — FT Visual Vocabulary (Part-to-whole) + Datawrapper (stacked bar for "a few
  shares"; bar "when precise reading matters") — paired with absolute byte labels for the precise read.
- **Unavailable degrades, not disappears.** Snippets + datasets are measured from our own data, so
  they always show; only the **App** segment (which needs the origin estimate) drops out when the
  Storage Manager API is absent.
- **No proactive "almost full" warning.** A percentage gauge would key off the untrustworthy
  quota, and a fake fuel gauge fails NN/g #1 (_visibility of system status_) more than it
  serves it. The genuine out-of-room event surfaces at **save time** as an
  actionable error (`services/storage-errors.ts` → recover by deleting), satisfying NN/g #9.

_(Consulted via /council → WAI-ARIA APG `meter`, FT Visual Vocabulary + Datawrapper (part-to-whole),
web.dev storage, NN/g #1/#9, WCAG 1.4.1. This bullet is the contract; cite it, not the sources.)_

**Resolved — library search (Carbon active-search).** The snippet-library search (spec
§02) is an **unlabelled active-search input** pinned above the list: `type="search"` with a
leading magnifier and `aria-label="Search snippets"` (no visible label — the icon +
placeholder name it), filtering the list on **each keystroke** (no Search button, no results
page). A **clear (✕)** appears only when the box is non-empty; it empties the box **and
returns focus to the input** (NN/g #3 user control). Matching is case-insensitive substring
across **name + comment + draft spec text** (the pure `snippetMatchesQuery` in
`core/snippet-sort.ts`). Search affects **visibility only** — it never changes
`activeSnippetId` or any data. A **polite `aria-live` region announces the result count,
including no results** (Carbon: _"always include the number of results, including no
results"_); it stays silent for the default unfiltered list. _(Consulted via /council →
Carbon search/active-search, NN/g #3. This bullet is the contract; cite it, not the source.)_

**Resolved — library sort (APG menu-button + NN/g recognition).** The Sort control (spec
§02) reuses the **disclosure popover** primitive (the settings-popover model above), **not**
an ARIA menu — but its trigger shows the **current state for recognition** ("Modified
↓"), per NN/g #6 (recognition over recall), instead of a bare gear. (The visible text drops
the "Sort:" verb prefix to stay compact in the narrow library rail — §8; the full name
"Sort by Modified, descending" rides in `aria-label`.) Fields are **Modified /
Created / Name / Size**; the active field shows a **direction arrow** (↓ desc / ↑ asc) in both
the trigger and the field row, and the arrow's meaning is mirrored into the field's
`aria-label` ("Modified, descending") so it isn't carried by the glyph alone. **Selection
model** (spec §02): re-selecting the **active** field flips direction; selecting a
**different** field switches to it and **resets to descending** — encapsulated in
`SnippetStore.setSort` and tested in core. APG disclosure keyboard/focus is inherited from
the shared popover (Enter/Space open; **Esc closes and returns focus to the trigger**; outside
click closes; one open at a time). Field+direction **persist across sessions** (ux-prefs;
default Modified/desc); search is **not** persisted (it's a transient view narrowing).
_(Consulted via /council → WAI-ARIA APG disclosure + menu-button, NN/g #6. This bullet is the
contract; cite it, not the source.)_

**Resolved — the empty-library onboarding canvas (and the list's single empty state).** The
snippet list owes **one empty state** — **no search matches** ("No snippets match your search",
with a hint to try a different term). There is **no separate "empty library" list state**,
because a genuinely empty library never shows the list at all: rather than seeding a placeholder
snippet (the old behavior), the workspace replaces the **entire pane chrome — toggle strip,
library list, editor, and preview — with a full-width onboarding canvas** (spec §02 →
First-Run & Empty Workspace): with no snippets, the library controls and pane toggles have
nothing to act on. (So the list's own empty copy is reached only mid-search, never on a cold
start.) The canvas is a welcome, a primary "Create your first snippet", and a gallery of
example snippets; leaving it (creating the first snippet) lays the panes out at a default
**25·25·50** split via `PanesStore.applyOnboardingSplit` so the first chart opens with a
generous preview. Each card **renders live through the shared `chart-renderer`** (no parallel
embed path; each card owns its `RenderHandle` and finalizes on unmount — the per-card nodes are
independent, so they don't touch `LivePreview`'s single-host serialization), and adds as an
ordinary snippet. Empty stays calm and positive, never an error (NN/g aesthetic-and-minimalist;
§3 empty ≠ error). Three rules govern the canvas. (1) **Each card preview is decorative** —
`aria-hidden`, skipped by screen readers (Carbon empty-states a11y / WCAG decorative images);
the card **name + one-line description + a uniquely-labelled `Add` button** ("Add Bar chart",
APG button) carry the meaning, so AT users reach no dead end. (2) The canvas is the **single
empty surface** — because it replaces the library list outright, there is no competing "No
snippets yet" status elsewhere to keep in sync. Its heading **owns the app identity** ("Welcome
to Astrolabe") and it carries the only Create nudge, so the empty-state message lives in exactly
one place (Carbon "keep words to a minimum"; no duplication). (3) The **primary action
dominates** (accent "Create your first snippet" first; the example gallery is framed as a
secondary "Or start from an example") — Carbon sanctions starter content as an in-depth
first-use empty state only when one action stays primary. (4) **Domain vocabulary stands**:
"Vega-Lite", "JSON", "snippet" are kept despite Carbon's avoid-jargon rule, because SOUL #2
(Vega-Lite Native) makes them the user's real language — a deliberate divergence. _(Consulted
via /council → Carbon empty-state + content, GOV.UK headings, WAI-ARIA APG button, NN/g. This
bullet is the contract; cite it, not the source.)_

**Resolved — the creation surface is builder-forward, Monaco-intact (3D).** The guided
path must be visible where the intent to make a chart forms: the library's creation surface
is a **primary "Build Chart"** (opens the Chart Builder) beside a **ghost "New JSON
snippet"** (the old instant create, unchanged) — two plain buttons with clear hierarchy, no
split/menu-button widget (two static choices don't earn an ARIA menu). The expert path
stays one visible click away, never hidden (NN/g #6 recognition over recall, #7 flexibility/
efficiency; Carbon: one primary per surface, a tertiary for the secondary CTA). The builder
itself opens **without a preselected dataset** (it picks the most recently modified; a
header **Dataset picker** switches without leaving), and with an empty dataset library it
shows a **no-datasets state** per the Carbon no-data pattern: what the space does + one
primary next step ("Add a dataset" → the Datasets create form) — never a dead end. The
dataset-row "Build Chart" stays as the contextual shortcut; the onboarding canvas gains the
data-first door ("Build a chart from your data") beside its primary. _(Consulted via
/council → NN/g #6/#7, Carbon empty-states; recorded in
docs/exploration/chart-builder-enhancement-scope.md §3 · 3D. This bullet is the contract; cite it, not
the 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 — inline _live_ validation feedback is polite, glyphed, and field-linked.** A
validator that re-checks on **every keystroke** (the Chart Builder expression inputs — a
calculated field, a filter in expression mode) is **not** an assertive `alert`: that would
interrupt on each character (APG _Alert_ "avoid frequent interruptions"; WCAG 2.2.4). It is a
**polite `role="status"`** line, and severity reads from a **status glyph** (round error /
triangle warning, arch 09 §5.2) **plus** colour — never colour alone (§3; WCAG 1.4.1), exactly
like the guidance warnings above it. The owning `<input>` carries `aria-invalid` for the state
and `aria-describedby` pointing at the message node so the text is available on focus, not only
when it changes (GOV.UK _error-message_ field association). Contrast the editor's **render**
error, which is a discrete, post-debounce result and stays the single `alert` of the rule
above. _(Consulted via /council → WAI-ARIA APG Alert, GOV.UK error-message, NN/g #9. This
bullet is the contract; cite it, not the source.)_

**Resolved — feature-modal dismissal & initial focus.** A feature modal (Datasets, 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 — settings are distributed, not a modal; each cluster is a disclosure popover.**
Preferences (spec §07) live next to what they affect and apply **live**: theme is the header
toggle, editor settings open from the editor toolbar, render debounce from the preview, date
format from the library. This matches the already-distributed theme + fit-mode controls,
makes a change's effect visible in the pane being configured, and keeps each block
independently extensible — so there is **no central Settings modal and no Apply/Cancel/dirty
commit step** (changes are individually reversible; the editor cluster offers a Reset). The
disclosure mechanism is a **gear button + non-modal popover**, _not_ an ARIA menu: a menu
lists actions/commands (`menuitem`/`menuitemcheckbox`/`menuitemradio`), but these panels hold
sliders, number/text inputs, and radio groups, so the container is a labelled `group`. The
gear carries `aria-expanded` + `aria-controls`; Enter/Space toggle; **Esc closes and returns
focus to the gear**; an outside click closes; at most one is open at a time; focus moves to
the first control on open (so `Cmd/Ctrl+,`, which opens the editor cluster, lands inside it).
Non-modal — **no focus trap** (unlike the feature modal above). The panel is portaled to
`<body>` and positioned `fixed` because the panes clip their content. The same primitive and
single-open registry serve any pane-header disclosure, not only settings: the per-chart
**Export** control (preview header — _Import & Export → Per-chart export_) is a disclosure
whose `group` holds a few **action buttons** (Copy / Download) plus option controls. A small
set of action buttons in a disclosure stays a `group` — an ARIA menu is reserved for true
`menuitem`/`menuitemcheckbox`/`menuitemradio` command lists, which this app does not use.
_(Consulted via /council → NN/g #4 consistency, #6 recognition-over-recall, #8 minimalist;
WAI-ARIA APG disclosure + menu-and-menubar; Carbon popover/overflow-menu/text-toolbar. This
bullet is the contract.)_

**Resolved — value pickers are the SelectControl disclosure, not native `<select>`.** A
native select's popup can't be token-styled and renders differently on every browser/OS — a
foreign object inside a designed surface — so anywhere a control is part of one,
`SelectControl` replaces it: the same disclosure primitive as the settings popovers (trigger
with `aria-expanded`/`aria-controls`; portaled, `fixed`-positioned panel; labelled `group` of
option buttons — **not** an ARIA menu or combobox; single-open registry; Esc closes and
refocuses the trigger; outside press closes; open lands focus on the selected option;
Arrow/Home/End rove). The selected option carries `aria-current` and a visible ✓, never
colour alone. The same control doubles as an **action picker** (no `value`; e.g. "Add field
to which channel?"). A custom `triggerClassName` _replaces_ the default trigger styling, so
chip-styled triggers (the pill's type chip, the shelf's field chips) stay chips; the default
trigger sits on the **32px compact control scale** (arch 09 §6), like the sort trigger and
search input it shares surfaces with. A long option list can carry **group separators**: an
option's `dividerBefore` draws a `role="presentation"` rule above it — purely visual, never
in the keyboard order, never a heading. **The boundary is set where the list is built**: the
module that decides the option order marks the divider-carrying option (e.g.
`chartThemeOptions` stamps the first preset); a consumer must never recompute a group
boundary by index arithmetic, which silently misplaces when the producer's ordering changes.
A value list may carry an **action row** (the VS Code theme-picker pattern — e.g. "Edit
themes…" inside the chart-theme picker): permissible because options are real buttons, not
listbox options (APG's no-interactive-children listbox constraint doesn't apply); the row's
label ends in "…" (the opens-further-UI convention) and sets the option's `hasPopup` so AT
hears `aria-haspopup` — no special visual styling beyond an adjacent group divider. The
default trigger caps its value label at **16ch with ellipsis**, so a long value (a preset or
user-named theme) can't blow out a crowded pane header; the full label remains in the open
list and the trigger's accessible name.
The single-open registry means **disclosures cannot nest**: a SelectControl inside a
settings popover would close — and unmount — its own parent on open. A control that needs
its own popover sits beside the gear in the pane header, never inside the panel.
_(Consulted via /council → WAI-ARIA APG disclosure/menu-button/radio, Carbon, NN/g #4. This
bullet is the contract; cite it, not the source.)_

**Resolved — editor commands need a visible home; hidden surfaces are accelerators only.**
A command that exists _only_ in Monaco's right-click context menu or F1 palette is
undiscoverable (NN/g #6 recognition-over-recall — those surfaces demand the user already
know the command exists). Every editor command gets a **visible toolbar home**; when the
toolbar can't afford a dedicated button (Carbon menu-buttons: "use an overflow menu when
additional options are available and there is a space constraint"), the home is a
SelectControl **action picker** grouping related commands (e.g. the spec editor's _Config_
menu: merge chart theme / extract config), with `detail` lines saying what each does.
Context-menu and palette registrations stay, as the NN/g #7 expert accelerators, but they
call the same functions as the visible control — one code path, two doors.
_(Consulted via /council → NN/g #6/#7, Carbon menu-buttons/overflow-menu. This bullet is
the contract; cite it, not the source.)_

**Resolved — content-gated toolbar actions hide; state-gated actions disable.** Two ways a
toolbar action can be inapplicable, with opposite affordances. **State-gated** — applicable
to this object in principle, just inert right now (Revert with no draft changes, Config in
the read-only Published view) → **disabled**: the user can act (edit / switch view) and it
lights up. **Content-gated** — inapplicable to _this spec's shape_, and nothing the user can
do in the moment changes that (_Extract to Dataset_ needs inline data; _Open in builder_
needs a builder-representable spec referencing an existing dataset) → **hidden**. A
permanently-disabled control the user cannot enable reads as broken or teasing, not as
guidance (NN/g #6 — a disabled state must imply "do X and this becomes available"; Carbon
button states). So _Open in builder_ (spec §06) sits in the editor toolbar beside _Extract
to Dataset_ and follows its visibility — present only when the active snippet round-trips
through the builder and its dataset exists. This refines "disabled is for temporarily
unavailable actions" (below) from the unbuilt-feature case to the per-spec case.
_(Consulted via /council → NN/g #4/#6, Carbon button usage/states. This bullet is the
contract; cite it, not the source.)_

**Resolved — field→channel assignment: explicit choice, visible armed state.** Clicking a
shelf field with no channel armed opens an explicit **channel chooser** (the channels that
accept the field; an occupied one is labelled with what it replaces) — never a silent
first-empty-seat grab (NN/g #3, user control). Arming a channel slot short-circuits the
chooser (the fast path) and **must be visible where the next click happens**: the shelf
gains an accent ring plus a polite `role="status"` line naming the target ("Assigning to X —
choose a field below. Esc cancels"); **Esc disarms** without closing the modal (captured
before the dialog's own Escape handling). Chart-level properties (title/subtitle,
width/height) live **on the chart side**, in a strip under the preview — a chart property
belongs on the chart (NN/g #4; the Tableau/Lyra convention). A link-styled affordance that
_acts_ rather than navigates is mis-dressed: such actions are **ghost buttons** with
verb-first labels (Carbon links-vs-buttons; "Use a constant"). _(Consulted via /council →
NN/g #1/#3/#4, Carbon button/link usage. This bullet is the contract.)_

**Resolved — the intent front door is an APG toolbar of toggle chips (Tier C "do it for me").**
The Chart Builder's _"What do you want to show?"_ strip (spec §06 → Intent) is a **WAI-ARIA
`toolbar`** (one tab stop, roving tabindex, `aria-labelledby` the visible heading) of chips —
**not** seven independently-tabbable buttons (the pane-toggle precedent above). Each chip is a
**toggle button** (`aria-pressed`) whose pressed state is **derived from the configuration**
(the chip whose recommended layout the live chart matches), never stored — so a hand-edit
resolves to "Custom" (none pressed) for free. Arrow keys **move focus only**; **Enter/Space
applies** — because applying an intent reshapes the whole chart, a radiogroup's select-on-arrow
would do that on every keypress (so this is a toolbar, not a `SegmentedControl` radiogroup).
Selection shows as an **accent ring, never an accent fill** (fill stays the primary-action
signal — arch 09 §3.3). Intents the dataset can't satisfy are **disabled via `aria-disabled`
and kept arrow-reachable** (APG: focusable disabled controls "where discoverability of a
function is crucial"), with the reason in the chip's accessible name (`aria-label`
"Correlation — needs two number columns") plus a `title` for sighted hover — **never hidden**
(Tableau _Show Me_). The chips are framed as **intents, not chart shapes** (FT Visual
Vocabulary / Datawrapper organize by intent); _Heatmap_ is the one chart-type label retained
— a deliberate divergence for recognizability that also mirrors the mark selector's _Heatmap_
label, so the intent and the mark read as one thing (revisit if it confuses). _(Consulted via /council →
APG toolbar + button(toggle); FT Visual Vocabulary / Datawrapper intent framing; NN/g #6
recognition. This bullet is the contract.)_

**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.

**Resolved — no affordance for unbuilt features.** A visible placeholder promising future
functionality (the Chart Builder's dashed "+ row facet · LATER" shelf slots, removed
2026-06-13) is roadmap language shipped to users: it speaks our planning vocabulary, not
theirs (NN/g #2), and competes with the working controls around it (NN/g #8). A gated or
later-phase feature gets **no placeholder, tag, or disabled stub** until it ships — disabled
states are for _temporarily unavailable_ actions, not unbuilt ones. Design the layout so the
future control can land without rework (e.g. shelf slots are row-shaped), and keep the
roadmap in `docs/`, not in the UI.

**Resolved — service-worker update prompt & persistent storage (web.dev seat).** The build
uses `registerType: 'prompt'`, so a new service worker waits and never takes over a running
session on its own — the app **must** tell the user, or "ask before updating" silently means
"never update." `orchestration/pwa.ts` consumes `virtual:pwa-register` and surfaces
`onNeedRefresh` as a **durable** (non-auto-dismissing) info toast with a **Reload** action
that calls `updateSW()`; `onOfflineReady` is a transient success toast. Separately, browser
storage is best-effort and evictable under pressure, which for a local-first workspace is data
loss — so we request `navigator.storage.persist()` once at startup
(`infrastructure/storage-persist`), feature-detected and silent on denial (Chromium decides
automatically; nothing for the user to act on). _(Consulted via /council → web.dev, with the
exact API from vite-plugin-pwa/Workbox; see `reference/principles/web-dev.md`. This bullet is
the contract; cite it, not the source.) **Known gap:** the manifest ships no icons, so the app
is not yet installable — a design-asset task, logged not passed._

## 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).

---

## 7. Revealed actions & destructive affordances

How row/list actions appear, and how dangerous ones signal themselves. (Pairs with the
iconography contract, [arch 09 §5](09-visual-design.md).)

- **Reveal-on-hover is a per-surface choice, not a default.** Hiding a control until hover
  cuts clutter in a **dense, repeated** list the user inevitably traverses (the snippet-row
  delete) — there, arrival is guaranteed, so discoverability isn't lost. But a **rare or
  load-bearing** action must stay **always-visible**, or it becomes effectively unreachable
  (NN/g #6 — recognition over recall; a feature you can't see you can't use). Decide per
  surface; when in doubt, show it.
- **A hover-revealed control must also reveal on keyboard focus.** Gate visibility on
  `:hover` **and** `:focus-within`/`:focus-visible`, never hover alone — otherwise the
  action is mouse-only and invisible to keyboard users (WCAG 2.1.1). The snippet row reveals
  its delete on `.item:hover` _and_ `.delete:focus-visible`.
- **Destructive controls signal danger on hover _and_ focus.** A delete/remove affordance
  reddens to `--support-error` on both `:hover` and `:focus-visible` — not colour-by-mouse
  only — so the warning reaches keyboard users at parity. Colour is a _reinforcement_ here,
  never the sole signal: the control still carries its label/`aria-label` and the
  consequential ones still route through a confirm dialog (§4).

---

## 8. Space-constrained controls (responsive collapse)

The three work panes resize independently, so a toolbar's room is a function of its
**pane's** width, not the viewport's. Controls must stay usable at the pane minimum
without clipping, wrapping, or crowding.

- **Query the pane, not the window.** Use a CSS **container query**
  (`container-type: inline-size` on the row, `@container` on the controls), not a
  media query — the pane width is what changed. Scope the container to the toolbar
  row itself, away from heavy children (e.g. the Monaco editor) whose own layout
  shouldn't inherit size containment.
- **Shed labels under pressure; keep a contested primary labelled.** When a toolbar
  would wrap or crowd, **secondary** actions collapse to icon-only and the label
  moves to `aria-label`/`title`. A **primary that shares the row with secondaries**
  keeps its text — the label is what marks it as _the_ action to take (the editor
  toolbar below ~480px: Publish stays "Publish"; Extract/Revert become glyphs). A
  **standalone primary CTA**, whose prominence is carried by fill + size + position
  rather than its words, _may_ collapse to a universal-set icon at the pane floor
  (the library's "Create New Snippet" → "+" below ~250px). Either way it's a
  degradation that preserves the accessible name — distinct from the closed
  icon-only set (arch 09 §5.1 rule 4).
- **Trim a compact trigger's prose, not its state.** A disclosure trigger that names
  its current state for recognition (NN/g #6) may drop the **verb prefix** to fit a
  narrow rail — the library Sort trigger shows "Modified ↓", not "Sort: Modified ↓"
  — but the full name (`aria-label="Sort by Modified, descending"`) is preserved for
  assistive tech, so only redundant visible words are cut.
- **A flex control must be able to shrink.** A side control (a Sort button) carries
  `flex: 0 0 auto` so the flexible field (search) absorbs the slack; the field's
  `<input>` needs `min-width: 0`, or its intrinsic ~20ch width overflows the slot
  and overlaps its neighbour. Right-aligned toolbars (`justify-content: flex-end`)
  clip their **leftmost** item on overflow — left-align so the trimmable end is a
  settings affordance, not a primary control, and size the pane minimum so it
  doesn't overflow at all.

---

## 9. Organizing a large control surface (two levels)

A control surface too big for one scroll (the Theme Builder spans most of the
Vega-Lite config) is organized in two levels, each with a settled widget so the
choice isn't re-litigated per surface:

- **Level 1 — switch by domain with tabs.** Mutually-exclusive top-level
  categories (Color, Marks, Type, …) are an APG **tab set**, one panel visible at
  a time. Past a handful of tabs a horizontal strip wraps raggedly and the active
  tab shifts rows; a **vertical tab list** (`aria-orientation="vertical"`, Up/Down
  - Home/End) scales without wrapping and keeps the active panel anchored. Never
    **nest** tab sets — two roving tablists collide.
- **Level 2 — group within a panel by how it's read.** Sub-groups a user reads in
  full get **flat headings** (`role="group"` labelled by the heading). Sub-groups
  where a user tunes one or two and skips the rest get a **single-expand
  accordion** (APG accordion — heading-button toggles a `role="region"`; Up/Down
  between headers) — Carbon's rule: accordion is for content "not crucial to read
  in full." A per-section **modified badge** (count of set properties) keeps an
  override scannable while collapsed (NN/g #6, recognition).

_(Consulted via /council → WAI-ARIA APG tabs/accordion/disclosure, IBM Carbon
accordion usage, NN/g #6/#8.)_

## 10. Product claims & promise copy

Declarative copy — the landing, the About modal, onboarding, empty-state value props —
makes **claims** about the product, not just feedback about an action. The care the §3
triad gives error wording applies here too: **say only what we can certify, and say it
once.** An overstated claim reads as insecurity, and the first time a user catches one
being false it costs more trust than the claim ever bought (NN/g credibility; GOV.UK
"don't oversell"). The test is not modesty for its own sake — it is that every sentence
survives a skeptical reading.

- **Claim what we can certify — not the future, not what we don't control.** "No account,
  no server" is structural and always true (there is no backend). "Never leave your machine"
  is a vow over every future build and every edge case; state the posture instead — "stored
  locally on your device; there's no server to send them to."
- **No absolutes.** _never · always · fully · entirely · everything._ One edge case or one
  future feature falsifies them, and the reader feels the overreach even when it happens to
  hold. Prefer the scoped form: "works offline" over "fully offline"; "your library lives in
  the browser" over "everything lives in the browser."
- **Don't promise durability the platform doesn't back.** Browser storage (IndexedDB, no
  `persist()`) is best-effort and the browser may evict it. A chart is **saved**, not kept
  forever — route the permanence claim through **export**, which is the real backup.
- **State a posture once per surface.** Repeating "local / no account / no server / offline"
  across the hero, the lede, and a feature grid is three chances to sound unsure of it. Give
  the posture one home and let the other surfaces describe the product.
- **Match the register, and don't under-sell.** Astrolabe is a free, spare-time tool: the
  voice is plain and matter-of-fact, not manifesto. But concrete, true capabilities —
  portable Vega-Lite JSON, two authoring modes, custom themes — are claims worth making
  plainly. Reducing promises means cutting the _uncertain_ ones, never the real ones.

SOUL.md §"Local-Only by Default" is the internal **intent** and may be absolute; this
section governs how that intent is **phrased to users**, where the promise should be only as
strong as we can keep.

---

## 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)".
- In product claims, say only what we can certify, once per surface; route durability
  through export.
- 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).
- Don't use absolutes in product claims (never/always/fully/everything) or promise what the
  platform can't keep — say "saved," not "permanent."
