Codify the spec-vs-playbook boundary: cite behavior, don't restate it

This commit is contained in:
2026-06-09 15:02:12 +03:00
parent 81f563a3e7
commit 29b0d1adb0
2 changed files with 74 additions and 46 deletions
+32 -12
View File
@@ -7,7 +7,26 @@
>
> They are the architectural counterpart to [`docs/spec/`](../spec/): the **spec** says
> _what the app does_ (behavior, acceptance points); this **playbook** says _how we build
> it_ (state, persistence, modals, routing, rendering, inference, relationships).
> it_ (state, persistence, modals, routing, rendering, inference, relationships, and the
> visual + interaction language).
## Spec vs playbook: cite, don't restate
The two documents overlap most in §09 (visual) and §10 (interaction), which necessarily talk
about user-facing widgets. At that overlap, one rule keeps them from drifting:
- The **spec owns product behavior** — what features exist, what surfaces appear when, what
the data does. The **playbook owns the _how_** — state shape, persistence, ARIA roles,
keyboard models, focus, tokens, motion.
- **For product behavior, the spec is the source: cite it (`spec §NN`), don't restate it,
and never contradict it.** A playbook bullet may _name_ the behavior in one clause and cite
the spec, then spend its words on the _how_ (the role, the keys, the focus move) and the
_why_ (the council/canon citation). When a playbook bullet and the spec disagree, the
**spec wins** and the bullet is the bug.
_(This bit us once: arch §10 described "keep the library list, title-only" while spec §02
said "replace the entire list with the onboarding canvas" — the two halves were authored from
opposite mental models. The restatement, not the separation, was the leak.)_
## How to use this playbook
@@ -19,17 +38,18 @@
## The documents
| # | Doc | Covers |
| --- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 01 | [State & Stores](01-state-and-stores.md) | Zustand stores; one source of truth; selector derivations; central `useAppStore` vs per-feature stores; testable action functions; debounced auto-save. |
| 02 | [Persistence](02-persistence.md) | The infrastructure-adapter boundary; promise-wrapped IndexedDB wrapper; lazy data loading; per-record schema versioning + migration; localStorage prefs with fallback; storage tiers + quota monitoring. |
| 03 | [Modal System](03-modal-system.md) | Registry + coordinator + shell; one modal at a time; unsaved-change detection via snapshot; focus trap; backdrop/Escape/close dismissal. |
| 04 | [Routing & Events](04-routing-and-events.md) | URL hash as view-state (restore/sync, Back/Forward); global keyboard routing; Escape priority chain; the single-source `isInInteractiveContext()` helper (Monaco-aware). |
| 05 | [Rendering, Theming & Preview](05-rendering-theming-preview.md) | vega-embed integration (`actions:false`, `view.finalize()`); field-name escaping; theme→config mapping; debounced non-blocking renderer; resilient error display. |
| 06 | [Type Inference & Profiling](06-type-inference.md) | Pure, portable column-type inference (number/text/date/boolean) and the dataset profile shape. |
| 07 | [Naming & Relationships](07-naming-and-relationships.md) | Unique-name enforcement + import auto-suffix; the bidirectional snippet↔dataset name link; rename propagation into specs. |
| 08 | [vega/editor Techniques](08-vega-editor-techniques.md) | Reference brief: borrowable Monaco-schema wiring, vega-embed lifecycle, two-tier validation, and data-flow/debounce techniques distilled from the official Vega-Lite editor — plus where we do better. |
| 09 | [Visual Design Language](09-visual-design.md) | The _visual_ contract: principles inspired by IBM/Carbon, deliberate divergences (square chrome, free color/theming), the token system (Plex type, 8px spacing, role-based color, motion), component conventions, and where to mine the Carbon/IBM source repos for more. Companion: [`visual-specimen.html`](visual-specimen.html). |
| # | Doc | Covers |
| --- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 01 | [State & Stores](01-state-and-stores.md) | Zustand stores; one source of truth; selector derivations; central `useAppStore` vs per-feature stores; testable action functions; debounced auto-save. |
| 02 | [Persistence](02-persistence.md) | The infrastructure-adapter boundary; promise-wrapped IndexedDB wrapper; lazy data loading; per-record schema versioning + migration; localStorage prefs with fallback; storage tiers + quota monitoring. |
| 03 | [Modal System](03-modal-system.md) | Registry + coordinator + shell; one modal at a time; unsaved-change detection via snapshot; focus trap; backdrop/Escape/close dismissal. |
| 04 | [Routing & Events](04-routing-and-events.md) | URL hash as view-state (restore/sync, Back/Forward); global keyboard routing; Escape priority chain; the single-source `isInInteractiveContext()` helper (Monaco-aware). |
| 05 | [Rendering, Theming & Preview](05-rendering-theming-preview.md) | vega-embed integration (`actions:false`, `view.finalize()`); field-name escaping; theme→config mapping; debounced non-blocking renderer; resilient error display. |
| 06 | [Type Inference & Profiling](06-type-inference.md) | Pure, portable column-type inference (number/text/date/boolean) and the dataset profile shape. |
| 07 | [Naming & Relationships](07-naming-and-relationships.md) | Unique-name enforcement + import auto-suffix; the bidirectional snippet↔dataset name link; rename propagation into specs. |
| 08 | [vega/editor Techniques](08-vega-editor-techniques.md) | Reference brief: borrowable Monaco-schema wiring, vega-embed lifecycle, two-tier validation, and data-flow/debounce techniques distilled from the official Vega-Lite editor — plus where we do better. |
| 09 | [Visual Design Language](09-visual-design.md) | The _visual_ contract: principles inspired by IBM/Carbon, deliberate divergences (square chrome, free color/theming), the token system (Plex type, 8px spacing, role-based color, motion), component conventions, and where to mine the Carbon/IBM source repos for more. Companion: [`visual-specimen.html`](visual-specimen.html). |
| 10 | [Interaction & Feedback](10-interaction-and-feedback.md) | The _interaction_ contract: the feedback-channel decision table, latency/feedback budgets, the non-happy-path triad, the recovery & data-safety contract, the keyboard/focus contract, and the resolved widget patterns (window splitter, toolbar, segmented controls, selectable lists, search, sort, empty states, modals). Cites `spec/` for behavior; owns the _how_. |
## The non-negotiable layering (every doc assumes this)
@@ -18,6 +18,14 @@ IBM Carbon, the **GOV.UK Design System**, the **WAI-ARIA Authoring Practices Gui
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
@@ -224,9 +232,11 @@ forbids interactive children in a listbox. Mark the active row with `aria-curren
**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.** The library row flags only the _exceptional_
state: a single **accent dot** when a snippet has unpublished draft changes; a fully-published
snippet shows **no dot** (presence = draft, absence = published). We do **not** give "published"
**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
@@ -266,37 +276,35 @@ _(Consulted via /council → WAI-ARIA APG disclosure + menu-button, NN/g #6. Thi
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"
- 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.)_
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