mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
792 lines
59 KiB
Markdown
792 lines
59 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/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 — composition structure wireframe.** The preview toolbar's structure disclosure (a
|
||
schematic of the spec's multi-view composition — `CompositionWireframe`, arch 08) is a
|
||
**WAI-ARIA APG `tree`** inside a disclosure popover (`usePopover`): bare nested boxes are
|
||
`tree` → `treeitem` → `group`, single-select via `aria-selected`, **one tab stop with a roving
|
||
tabindex**, arrow keys in **logical (document) order** — Up/Down between nodes, Left → parent,
|
||
Right → first child, Home/End, Enter/Space activate — not spatial, since a mixed horizontal/
|
||
vertical layout makes spatial arrows ambiguous. Each leaf carries a glyph of its mark type (the
|
||
`mark-*` icon sub-family, arch 09 §5) so views read apart at a glance. A `layer` — one plotting
|
||
space with several marks stacked — renders as **one frame** holding its child marks as a row of
|
||
glyphs, badged as layered (the `layers` glyph), rather than the box-per-view of a concat; each
|
||
mark stays an individual `treeitem` so selection and z-order reorder still work. Selecting a box reveals +
|
||
selects that view's source range in the editor (`AppStore.requestRevealView`) but **does not steal
|
||
focus**, so the wireframe stays the active surface while the editor scrolls to follow; the editor
|
||
selection is the single source of truth. The toolbar glyph appears **only for a composed spec** —
|
||
a single-view spec hides the affordance rather than disclosing an empty tree. _(Council: APG
|
||
treeview; the cursor-scoping reachability rationale is in [arch 08](08-vega-editor-techniques.md).)_
|
||
|
||
On the **editable draft** the tree restructures the composition. Every restructure is **applied by
|
||
the editor** (which owns the one-⌘Z edit) via `AppStore.requestComposeMove`/`requestComposeWrap`,
|
||
never by writing the draft text directly — so the wireframe and editor share one undo history.
|
||
|
||
- **Reorder within a container — APG rearrangeable-listbox.** `Alt+↑`/`Alt+↓` moves the focused
|
||
view among its siblings: a direct modifier+arrow move, **not** a grab/drop mode. Focus follows
|
||
the moved box for consecutive moves (so a screen reader re-announces its new position), a
|
||
**polite** live region states the result, and `aria-keyshortcuts` advertises the keys. _(Council:
|
||
APG listbox-rearrangeable.)_
|
||
- **Restructure by drag — zone model against the children's box.** Intent is read from where the
|
||
pointer falls relative to a row/column's children, not one nearest edge, so each gesture owns a
|
||
generous target: the **interior central band reorders** (an insertion slot by main-axis position —
|
||
a drag _along_ the block rearranges it anywhere, not only on a sibling's edge); the **cross-axis
|
||
frame margin** (a row's top/bottom, a column's left/right — the gutter between frame and children,
|
||
or past the block) **pulls the source out** into a new full-span row/column wrapping the whole
|
||
container, the root included; a drop onto **a view's far cross edge** pairs the two in a
|
||
perpendicular split (`Shift` forces a pair from the centre). The source is the **innermost** view
|
||
under the pointer — `beginDrag` stops propagation so a nested ancestor frame, itself draggable,
|
||
can't claim the drag (un-stopped, its handler runs last on bubble and wins). The hit-test descends
|
||
only through `hconcat`/`vconcat` and treats `layer`/`facet`/`repeat`/grid as **opaque**. Feedback:
|
||
a cursor **chip** names the pending action, the target previews it (reorder line, pair half-split,
|
||
pull-out band), and every frame's pull-out margins glow faintly while dragging. The drag is a
|
||
pointer accelerator over keyboard-reachable capabilities (in-container reorder via `Alt+↑/↓`;
|
||
cross-container restructure via the editor's wrap actions), so it adds **no keyboard-only gap**.
|
||
Transform invariants in [arch 08](08-vega-editor-techniques.md).
|
||
|
||
**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."
|