mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
531 lines
39 KiB
Markdown
531 lines
39 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.
|
||
|
||
**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/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.
|
||
|
||
_(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.
|
||
- **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. _(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.** 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.** Dropping the old 0.8/0.95 thresholds is intentional —
|
||
they keyed 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). Council resolutions for 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 — 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, 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. _(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 — 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 — 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.
|
||
|
||
---
|
||
|
||
## 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).
|