# Candlelight — a portable design system

**Status:** v1.9 — extracted July 11, 2026 from Lorelight (production)
**Changelog:** v1.0 — initial extraction (tokens, grammar, Ink/Sheet contracts, governance) · v1.1 (same day) — agent bootstrap protocol (§0.a), the console contract (§7.a), Appendices B–D (Tailwind mapping, structural/motion CSS, lint guards); the source app shipped the review's fixes (`6ceaec5`), so the traps §0.a cites are fixed there and documented here. · v1.2 (July 12, 2026) — Appendix C cascade repair: every tinted wash in the physics derives from its token via `color-mix` (pad-hit, key-cue hover, room-glow defaults from `--brass`/`--live`; slider track names `--hairline`; near-ink washes derive from `--ink`), so retuning the accent per §2.9 reaches the physics. Same computed values at the reference hues; no visual change. · v1.3 (July 12, 2026) — the tint ramp (§2.4a): the voice-color alpha stops get names (`--tint-fill` 10% / `--tint-chip` 12% / `--tint-held` 15% / `--tint-rim` 45%) and theme mappings (voice × stop, Appendix B); ad-hoc voice alphas ≤50 are lint-banned (Appendix D). Rims that sat at 30–40 normalize to the one rim stop; HoldToStrike's cold resting rim and sweep stay bespoke by contract. · v1.4 (July 12, 2026) — the contract metrics get one home (Appendix A): the law numbers ship as importable constants (`@candlelight-ds/tokens/metrics`); components import instead of re-declaring; law tests pin the values. · v1.5 (July 12, 2026) — copy extraction (§9): registry components hold no fixed words — every user-facing string became a prop with an English default, so a consumer's copy module reaches every word. · v1.6 (July 12, 2026) — the density system (§2.6) is real: the shells own `data-density` (StageShell → performance, RoomShell/SplitShell → bench), components carry both postures via the ambient descendant variant on a base-is-home-posture convention, and the catalog demos both postures. · v1.7 (July 13, 2026) — the baseline grid (§2.6a): `--baseline: 4px` is law; every type step bundles a baseline-locked leading (the grid is inherited at the token layer); arbitrary `leading-[Npx]` is lint-banned; the `.baseline-overlay` physics (Appendix C) + the GridOverlay registry item make the rhythm visible and checkable — a grid you can't toggle on and measure is a mood board. · v1.8 (July 13, 2026) — the composition rules (§2.6a): leadings on the grid are necessary, not sufficient — POSITIONS must land. Edges are paint, not mass (`hairline-t`/`hairline-b`, ring-inset; a layout border knocks the flow 1px off the rhythm); a rule sits inside a baseline-height band (MenuSeparator); rows are padding-built (36 = 20+16, 48 = 24+24), never min-height-centered; fixed bands center optically by scope. The overlay draws ONE weight (a heavier major fakes misalignment where leadings legitimately mix) and gains the measuring pass: off-baseline text wears the danger outline. · v1.9 (July 13, 2026) — the layout system, end to end: spacing derives from the baseline by construction (--spacing: var(--baseline); arbitrary vertical px lint-banned, env() and the paired em shim legal); the layout tracks (--col-deck/--col-list, lockstep with the metrics) and container breakpoints (defaults wiped; `frame` 64rem) close; the grid verifier runs headless in CI — a rhythm regression is a red build (its probe measures TEXT BLOCKS, exempts items-baseline rows and fixed bands); direction is logical (§10 — physical left/right lint-banned; the Sheet's drag sign carries the writing direction); `candlelight-ds doctor` checks a consumer's overrides against §2.9; the console respects the device safe area; the overlay gains square mode.
**What this is:** the complete, reusable specification of Lorelight's design system — tokens, interaction grammar, component contracts, motion, voice, and governance — written so a different product in a different market can adopt it wholesale and end up with an *identical* system wearing different words.
**Provenance (safe for adopters to ignore):** distilled from `lorelight-v2-component-system.md` (the original law), `lorelight-v2-wysiwyg-editing.md` (the editing contract), the shipped token layer (`app/globals.css`), and a hands-on July 11 review of the production surfaces (`lorelight-ui-review-july-11.md`). Where this doc and the older PRDs disagree, this doc reflects what shipped and is current law (e.g., the drawer floats; push mode is dead; the display face is gone). A rendered reference lives at https://lorelight-mvp.vercel.app/design-system — the source system's living catalog.

---

## 0.a · For an AI coding agent with zero context

This document is self-contained: you do not need the source project, its repo, or any doc it cites. Everything normative is in §§1–11 and Appendices A–D. Protocol:

1. **Read §1 (philosophy) and §3 (grammar) in full before writing any code.** They are the acceptance criteria for everything else; components that don't cite a G-rule get rejected in review.
2. **Day zero, in order:** (a) paste Appendix A into your global stylesheet and Appendix B into your Tailwind v4 theme (or translate B to your styling system — the *names* are the API); (b) paste Appendix C (structural + motion CSS) below it; (c) install the lint guards from Appendix D; (d) create the `/design-system` living-catalog route, empty. Governance exists before the first component does (§11).
3. **Build order:** Tier 1 primitives (§4) on Radix (or your platform's equivalent a11y primitives) → the performance-surface shell (StageShell) + the console (§7.a — ONE implementation, both postures) → Tier 2 instruments → bench shells. Never build a feature screen before the primitive it needs exists and is cataloged.
4. **Component contracts, not implementations:** §6 (editing) and §7 (drawer) are exact behavioral specs — treat every bulleted clause as a test to write. Where a clause names a trap (cold-mount focus, suggestion-popup key priority), it is a bug that actually shipped once; do not re-learn it.
5. **Vocabulary:** pick the domain metaphor first (§12 step 1–2) and generate your rename table before scaffolding — component names, key verbs, and copy all derive from it. If you have no better metaphor, keep the theater words; they work for any tool with a "showtime."
6. **Definition of done, every PR:** cites a G-rule · catalog entry updated · lint clean · reduced-motion path exists · keyboard path exists · copy from the copy module in the §9 voice.

---

## 0 · How to adopt this system (the three moves)

Candlelight transfers to a new market with exactly three moves. Everything else copies verbatim.

1. **Keep the mechanics.** Every value in §2 (color ladders, fill ramp, radii, shadows, z, motion), every rule in §3–§9, and the governance in §11 are market-agnostic. Copy them unchanged. They are one calibrated instrument; retuning parts independently is how systems die.
2. **Swap the vocabulary.** The system is built on a *physical workplace metaphor* — Lorelight's is a theater (stage, bench, GO, strike, announcer, setlist). Choose the equivalent metaphor for your domain and rename consistently (worked example in §12). The metaphor is load-bearing: it names components, keyboard verbs, and copy voice.
3. **Retune one hue.** The identity accent ("brass") may move to your brand's metal/material. Keep its three-step temperature structure (base / hot / deep), its jobs (§2.3), and its restraint (≤10% of any surface). The dark stage ladder and the ink ladder stay.

**What you may NOT change** (these are the system): the fill-ramp alphas, the one-ease motion set and its budgets, the three-radii/three-shadow/five-z scales, the interaction grammar (§3), the editing contract (§6), the drawer contract (§7), the console contract and its one-instrument law (§7.a), one-menu-engine (§8), the announcer rule (§8), and governance (§11).

---

## 1 · The philosophy (five laws)

1. **The product is an instrument, not a dashboard.** The user is mid-performance with divided attention. Every surface answers "what do I press next," never "here is everything." Selection stages; commitment is one deliberate act (G1). Controls are physical: keys travel, held keys sit sunken, strength is structure — rim, weight, contrast — **never glow**. Glow means exactly one thing: *this is live right now* (G7).
2. **Obvious over clever.** No ledes, no counters, no celebration toasts, no collapsed-by-cleverness. Optionality collapses, reveals are instant, selects are optimistic. If a stranger pauses at a control, the control is wrong.
3. **One way to do each thing.** One drawer, one menu engine, one editing feel, one rename gesture, one shell grammar per posture. A second implementation of an existing pattern is a bug even when it's prettier.
4. **Text is simply always warm.** There is no edit mode anywhere. Rendered text IS the editor; the caret is the only mode; words persist continuously (§6). Forms exist only for genuine configuration (URLs, keys) — never for the user's own words.
5. **Governance or death.** The system lives as a public living catalog rendering the real components, enforced by lint. A component isn't done until cataloged; drift is a build error, not a review comment (§11).

**Two postures, one app.** Every screen belongs to either the **bench** (prep: writing, arranging, browsing — daytime, focused) or the **stage** (performance: glanceable, hands on keys, low light). Density, type size, and hit targets read the posture (§2.6). Name your product's two postures on day one.

---

## 2 · Foundations (the tokens)

Copy this block as-is; it is the shipped skin. (§2.7 defines what re-theming may touch.)

### 2.1 The stage ladder (backgrounds)

```css
--stage:     #0a0908;  /* the room's darkness — body bg, the only true black */
--surface:   #141210;  /* panels sitting on the stage */
--surface-2: #1b1814;  /* menus, overlays, cards — one step up */
--surface-3: #242019;  /* highest resting surface (secondary buttons) */
--hairline:        rgba(241, 231, 211, 0.10);  /* ALL borders */
--hairline-strong: rgba(241, 231, 211, 0.18);  /* hover/emphasis borders */
```

Warm near-blacks (hue ≈ 40°, not neutral gray) — the darkness itself carries the temperature. Panels are `--surface` at 60% over the stage with `backdrop-blur-sm` when layered over ambient light. **No gradients as identity** — place reads through accents and words, never through washes.

### 2.2 The ink ladder (text)

```css
--ink:       #f8f3eb;  /* primary text — AA against all surfaces */
--ink-mute:  #c9bfae;  /* secondary: descriptions, labels */
--ink-faint: #8f8779;  /* tertiary: placeholders, eyebrows, disabled */
```

Three steps, no more. Alpha-composed white (`text-white/60`) is banned — these are the only text colors, and lint enforces it.

### 2.3 The voice colors (semantic)

```css
--brass:      #d8b074;  /* THE identity accent: primary actions, selection, armed state, focus */
--brass-hot:  #e4c18e;  /* its hover temperature */
--brass-deep: #a8823f;  /* its pressed/recessed temperature */
--live:   #74c2bd;  /* "happening right now" — patina; never decorative */
--ready:  #9bb97f;  /* verified/ready */
--warn:   #d9a03f;  /* attention, degraded */
--danger: #e5484d;  /* destructive — cold, never inviting */
--ember:  #c96342;  /* destruction-in-progress (the hold-to-strike ring) */
```

Jobs, not decoration: the accent marks *what you're about to act on* (armed, selected, primary, focus ring). `--live` marks *what is acting now*. The two must be different hues — the eye separates "staged" from "live" without reading.

### 2.4 The fill ramp (interactive states)

```css
--fill-rest:     rgba(241, 231, 211, 0.03);  /* resting chrome */
--fill-hover:    rgba(241, 231, 211, 0.05);
--fill-active:   rgba(241, 231, 211, 0.08);  /* pressed / keyboard-highlighted */
--fill-selected: rgba(216, 176, 116, 0.09);  /* selection — accent-tinted */
```

**The single most load-bearing token decision:** components never compose alpha by hand. Every hover, press, and selection on every surface is one of these four. This is what makes thousands of call sites mechanical instead of judgment calls, and what makes the whole app feel like one material.

### 2.4a The tint ramp (voice-color washes)

```css
--tint-fill: 10%;  /* voice-colored hover/highlight wash */
--tint-chip: 12%;  /* the status chip's fill */
--tint-held: 15%;  /* pressed/held wash */
--tint-rim:  45%;  /* the armed/engaged rim: ring, border, focus warm-up */
```

The fill ramp paints *neutral* interaction; when a wash wears a **voice** color (§2.3) it is one of these four stops, composed with `color-mix` and named in the theme — `bg-live-chip`, `ring-brass-rim`, `border-danger-rim`, `bg-brass-held`: voice × stop, a closed set over brass/live/ready/warn/danger. Ad-hoc voice alphas at or below 50 (`bg-live/12`, `border-brass/40`) are lint-banned (Appendix D). Dims *above* 50 — the 80% eyebrow accent (§2.6), a hover dim on a solid — are a different job and stay slash-composed. One exemption by contract: HoldToStrike's resting rim (30) and its sweep fill (25) sit outside the ramp — destruction rests cold and rises through it; the exemption is inline, commented, and does not generalize.

### 2.5 Radii, elevation, z, spacing, physicality

```css
--radius-sm: 8px;   /* controls, menu items, inputs */
--radius-md: 16px;  /* panels, cards, overlays, the drawer */
--radius-lg: 24px;  /* the outermost shell only */

--shadow-raise: 0 8px 28px rgba(0,0,0,0.28);   /* cards */
--shadow-float: 0 16px 50px rgba(0,0,0,0.45);  /* menus, popovers */
--shadow-stage: 0 40px 120px rgba(0,0,0,0.6);  /* overlays, the drawer */

--z-surface: 0; --z-rail: 10; --z-float: 30; --z-overlay: 50; --z-announce: 60;
/* one owner (the overlay layer) above z-float; 999 does not exist */

/* spacing: 4/8/12/16/20/24/32 are the only sanctioned gaps */

/* machined physicality — buttons are milled metal, never glowing plastic */
--edge-machined: inset 0 1px 0 rgba(255,255,255,0.16), inset 0 -1px 0 rgba(0,0,0,0.3);
--edge-sunken:   inset 0 2px 4px rgba(0,0,0,0.45);  /* a held/toggled-down key */
/* press = active:translate-y-px — 1px of real travel, every key, everywhere */
```

### 2.6 Type

**One family.** Inter (or your market's equivalent workhorse sans), every role. Display is carried by *scale and tracking*, not a second face — the marquee is 68px / weight 400 / −0.04em on the performance surface, and that reads as display without another font. (A display face was tried and retired; comments referencing it are ghosts.)

Scale (px): **11** eyebrows/labels (uppercase, tracking 0.18–0.26em, `--ink-faint` or accent at 80%) · **12** dense meta · **13** the UI body default (menus, buttons, rows) · **15** panel/drawer titles (semibold, −0.01em) · **18** overlay titles · **20/28** bench headings · **68** the one marquee. Fixed rem-equivalent sizes — no fluid type in product UI. Prose line-length caps at 65–75ch; tables may run denser.

**Densities:** `data-density="performance"` (stage: 13–15px text, 48px hit targets, sparse) vs `"bench"` (12–13px text, 36px rows, compact). Components read density from the ambient attribute, never via props.

The mechanics, as law: **the shells own the attribute** — StageShell sets `performance`, RoomShell/SplitShell set `bench` (each overridable by a `density` prop on the shell, the ONE place posture is declared). Components carry both postures via the ambient descendant variant (`[[data-density=bench]_&]:…` in Tailwind) on top of a **base = home posture**: performance-native instruments (Pad, CommitKey, TransportButton, IntensityBands, the Console housing) compact under bench; bench-native chrome (Button, ArmedRow) grows to the 48px target under performance. A surface with no attribute renders every component at its shipped base — density never changes behavior, only geometry and type.

### 2.6a The baseline grid

Vertical rhythm is law, and it has one source of truth: `--baseline: 4px`. Every leading, control height, row, band, and vertical padding is a whole multiple; a **2px optical shim is permitted only inside a control whose OUTER height sits on the grid**. The system's metrics already obey it — rows 36, targets 48, header 72, strip 76 — and both densities land on the same rhythm.

**Every type step bundles a baseline-locked leading** — the grid is inherited at the token layer, so using `text-body` puts your line on the rhythm without asking: nano/micro → 12 · eyebrow/tooltip/meta → 16 · body/body-lg/title → 20 · overlay-title/heading → 24 · heading-lg → 32 · marquee → 68 (px, never unitless — a unitless leading on display type pushes the box off the grid). Explicit overrides use named `leading-*` steps on the rhythm; arbitrary `leading-[Npx]` is lint-banned (Appendix D).

**The composition rules — leadings on the grid are necessary, not sufficient; POSITIONS must land too:**

- **Edges are paint, not mass.** A border participates in layout: it displaces the flow by 1px and knocks everything after it off the baseline. Rules between rhythm zones use the painted edges (`hairline-t`/`hairline-b`, inset shadows); full-perimeter cases compose natively (`ring-1 ring-inset ring-hairline`). Borders remain legal on CONTROLS (buttons, chips, keys), whose outer box sits on the grid and whose interior centers optically.
- **A rule sits inside a baseline-height band.** A bare 1px divider in a flow (a separator) shifts every following sibling off the rhythm; wrap the pixel in a baseline-height band (MenuSeparator is the reference).
- **Rows are padding-built.** Row height = leading + vertical padding, exact on the grid (36 = 20+16 bench, 48 = 24+24 performance) — never `min-height` + centering, which halves leftovers into off-grid offsets.
- **Scope, honestly stated:** the law binds the FLOW axis — stacked rows, reading text, panels in a column. Fixed bands (the 72px header, the 76px strip, the console) center their single line optically, as a control does; that is a module, not a violation.

**The grid must be visible and MEASURED, or it is a mood board.** The `.baseline-overlay` physics (Appendix C) draws one line per unit at one weight — a heavier "major" every N units would imply an editorial baseline the system never promised and manufacture the look of misalignment wherever leadings legitimately mix. The GridOverlay component adds the measuring pass: it walks the audited surface and marks every text element whose top is off the rhythm (`[data-off-baseline]` wears the danger outline — verification accuses; alignment stays quiet). The overlay lives INSIDE the surface it audits — the same content box, a `relative` parent — never as a full-width sibling: an overlay in a different box drifts from the content at some width and lies about alignment. Toggle it from the surface map (the G key by convention) so the binding prints in the generated help.

### 2.7 Motion

```css
--dur-instant: 120ms;  /* physical feedback: press, travel, crossfade */
--dur-ui:      150ms;  /* chrome: hover, menus, reveals — the whole budget */
--dur-sheet:   150ms;  /* the drawer's slide */
--dur-scene:  2500ms;  /* THE one sanctioned slow move: world/atmosphere change */
--ease-stage: cubic-bezier(0.22, 1, 0.36, 1);  /* the only ease for chrome */
```

Choreography rules: **enter = rise 6px + fade** (`stage-rise`); **exit = fade only** (never slide out); **state change = color/fill only**. No bounce, no elastic, no page-load orchestration. The 2.5s scene duration exists to make one moment theatrical *by contrast with* an instant UI — a slow move is meaningful only if everything else is 150ms. Skeletons are a flat fill breathing opacity (1 → 0.55, 1.8s) — no shimmer wash. Every loop (breathe, pulse, flames) dies under `prefers-reduced-motion`; one-shot feedback becomes instant.

### 2.8 Focus & inputs

One focus voice: `:focus-visible { outline: 2px solid var(--brass); outline-offset: 2px }`. Text seats opt out — their focus is the caret plus a border warm-up (`focus:border-brass-rim`, the tint ramp's rim stop); **one indicator per control, never two**. Checkboxes 14px with accent fill; range sliders paint a hard two-zone progress (accent → hairline) with a 12px thumb — flat, not gradient. Scrollbars: thin, `--hairline`, hover to 0.2 alpha.

### 2.9 Re-theming contract

May change: the accent triad's hue (keep ΔL relationships), `--live`/`--ready`/`--warn` hues (keep their contrast against surfaces), the metaphor vocabulary, the marquee size (±20%). Must not change: stage/ink ladder lightness relationships, fill-ramp alphas, radii/shadow/z scales, durations/ease, spacing scale, one-family type rule.

---

## 3 · The interaction grammar (G1–G10)

Ten rules every component implements. If an interaction can't cite one, it doesn't ship.

- **G1 · Arm, then Go.** Selection is *staging*, never execution. Arrows/click arm (accent ring, read-ahead preview); one deliberate commit (Enter / the GO control) executes. Zero accidental commits; total keyboard flow.
- **G2 · Printed keys.** Any action used more than twice per session shows its key *on the control* (the Kbd chip). `?` opens the key map on every surface — rendered *from the live binding list*, so help cannot drift from reality. No hidden-shortcut culture.
- **G3 · Audition on demand.** Every assignable thing offers a low-stakes preview behind an **explicit trigger** (affordance on the row, or Space while armed). **Nothing ever acts on hover.** Preview → stage → commit is the universal try-before-buy loop.
- **G4 · Strike, don't confirm.** Destructive actions are **hold-to-strike**: hold ~600ms, an ember ring fills, release early cancels, completion deletes *with an undo announcement*. Undo is a deferred commit (a grace window), not a restore. Typed-name confirm survives only for cascade deletes.
- **G5 · The announcer, not toast confetti.** Direct manipulation is confirmed by the change itself plus at most one quiet transient line in a fixed spot ("Struck *X*. Undo ⌘Z"). Toasts are demoted to async outcomes only (upload finished, sync failed); max 2; every destructive one carries Undo.
- **G6 · Status is a dot until it isn't.** Ambient system state (connection, sync, readiness) is a small semantic-colored dot; click expands to detail. No persistent status bars, no preemptive banners.
- **G7 · Glow is state.** Ambient accent tint answers "what is live/where am I" — the app chrome warms with the live thing's color. Never decoration, reduced-motion safe. (Corollary, ruled July 11: control *strength* is structure — rim, weight, contrast. A strong button is machined, not radiant.)
- **G8 · One way to rename.** Everything renames in place, identically: click the text (or `R` while armed) → caret appears → Enter commits, Esc reverts. No dialogs for names, ever.
- **G9 · Drag is for order; menus are for membership.** Drag-and-drop only reorders within a visible list (with an ⌥↑/↓ keyboard path). Adding to collections is a picker/⌘K action — cross-panel drag is banned (undiscoverable).
- **G10 · No dead hover.** Hover either reveals affordances (row actions, the audition trigger, key hints) or does nothing. It never mutates, never plays, and a tint-only hover on an inert element is a bug.

### The composition rules (C1–C6)

G-rules govern behavior; C-rules govern what a surface IS — they prevent "every screen is the same list/detail table."

- **C1** Compose from the *moment of use*, not the data table. Every surface states its moment in one sentence in the catalog; no moment, no merge.
- **C2** One display-scale moment per surface, owned by the most important *object* (not the page name).
- **C3** The primary action wears the GO family's clothes — exactly one per surface.
- **C4** Light is place: surfaces carry a static ambient identity accent (which room am I in) distinct from G7's live glow (what is happening).
- **C5** Rows are for archives. Present-tense objects get composed treatments (cards, mantels, heroes); uniform rows are reserved for history and bulk.
- **C6** Every surface has a mandatory, cataloged signature element — the thing you'd recognize it by with the labels off.

---

## 4 · The component inventory

Three tiers + shells. Catalog format for every entry: *Job · Use when / Don't use when · Anatomy · Variants · Keyboard · States.* Mechanics ride on Radix primitives (dialog, menus, tooltip, slider, checkbox, tabs) skinned entirely in the system — **raw Radix imports outside `ui/` are lint-banned**. Names in parens are Lorelight's; rename per your metaphor.

### Tier 1 — primitives (quiet, done once)

| Component | Job & non-negotiables |
|---|---|
| **Button** | Every click that isn't a row or a pad. Variants: `primary` (accent fill, machined edge, **one per region**), `secondary`, `outline`, `quiet/ghost`, `destructive` (cold), `link`. Sizes 32/40/44; optional printed-key chip. All travel 1px on press. |
| **Chip** | Status/count/tag tokens: `live` (pulsing dot), `armed`, `ready`, `warn`, `danger`, `count`, `tag`. Never triggers a primary action. |
| **Kbd** | The printed key. Two sizes (inline on controls / help map). Renders ⌘ vs Ctrl by platform. |
| **Tooltip** | Radix, 400ms, one line of *explanation* (the Kbd carries the key). Mandatory on every icon-only button — lint bans `title=`. |
| **Menu** | ONE floating-menu engine for dropdown + right-click + overflow "…": items, icons, printed keys, checkbox items (`closeOnSelect:false` for rapid-fire), danger items, max one submenu level. Panel: `surface-2`, radius-sm, `shadow-float`, `stage-rise` in. |
| **Overlay** | The one mount above `z-float`: `modal` (460/640px) or `bare`. Focus trap, Esc, scroll lock, `shadow-stage`, scale-in. **Overlays never stack two deep** — a modal needing a picker uses an inline combobox. Modal is the last resort, not the first thought. |
| **Ink** | The editing feel — full contract in §6. |
| **InlineEdit** | The commit-once exception to Ink: create-inputs and rename-overlays inside navigating links, where continuous save would create-on-idle. De-chromed to match Ink at rest. |
| **Slider** | `onChange` (live feel) vs `onCommit` (persist once on release) split — hardware/API consumers never flood. Two-zone flat progress paint. |
| **SegmentedControl** | 2–4 visible exclusive options. >4 or unknown → Select/Combobox. |
| **Skeleton** | Flat breathing fill on `--fill-hover`, `rows` helper. Spinners banned outside buttons. |
| **Announcer + Toast** | G5. `announce(text)` renders the one transient line per surface region; toast viewport for async only. |
| **EmptyState** | Two variants (`panel`, `inline`); teaches the surface's verb, in voice; never "nothing here." |

### Tier 2 — instruments (first-of-their-kind, fully custom)

The controls that make the product feel like *equipment*. Build these; they don't exist in kits.

- **TransportButton** — persistent surface-level control: icon / micro-label / printed key / pressed tone; density-aware (compacts on bench; never prints a key that doesn't work).
- **GoButton (the GO family)** — the wide commit control for G1: dark console key, strong accent rim, lit legend, machined edge, 1px travel. Armed state pulses the rim's *temperature* (color only — no halo). Live state is not a button at all — it becomes the status token.
- **CuePad** — fire-and-forget one-shots in a grid; the strike animation squishes (scale 0.94) and blooms the accent, decaying like a drum pad LED, restartable mid-decay.
- **IntensityMeter / BandButton** — the 1–N stepped state selector with printed number keys.
- **ReadinessDot / StatusDot** — G6 embodied: semantic dot + tooltip + click-to-detail.
- **HoldToStrike** — G4 as a behavior wrapper usable on menu items and buttons: ~600ms hold, ember ring, release-cancels, auto-announce with Undo handle.
- **LiveFlames / activity meter** — small animated bars proving "sound/activity is flowing," CSS-pulsed in lists, signal-driven where it matters.
- **GlowPanel / RoomGlow** — G7/C4: per-card live tint and the app-level ambient layer (two radial washes, 2.5s transitions, optional 9s breathe loop, reduced-motion static).
- **SetlistRow (the armed row)** — the list row that composes the grammar: arm ring, live chip, readiness dot, in-place rename, hover-revealed actions, context menu, reorder handle.

### Shells (layouts)

- **StageShell** — the performance surface: full-bleed `header strip / main stage + deck / rail`, performance density. The rail collapses to an overlay drawer under ~900px; on phones the list leads and GO pins to the bottom, thumb-reachable.
- **RoomShell** — every bench room: one floating strip (min-height 76px: eyebrow · context left, verbs right) over ONE stage panel (radius-md, hairline, `surface/60` + backdrop blur). Rooms are *not* allowed bespoke anatomies — "looks like the performance surface" is a fact of the import graph.
- **SplitShell** — list + adjacent editor with URL-synced selection, the drawer (§7) built into the detail region.
- **The Console** — the persistent docked control strip at the app frame's bottom edge, present on every surface. Full contract in §7.a; the app shell owns a `console` slot the way it owns nav — surfaces never mount their own.
- **Overlay-center** — auth/join ceremonies: one centered card on the stage, no chrome.

---

## 5 · The keyboard contract

Each surface declares a **binding map**; a shared hook (`useSurfaceKeyboard`) owns listeners, a scope stack (overlays and text seats swallow keys correctly), and renders the `?` help overlay *from the map* — the help screen is generated, not written. Grammar-reserved keys everywhere: ↑↓ arm · ⏎ commit ("sacred to GO" — nothing else may take Enter at surface scope) · Esc steps down (focus → lens → closed) · Space audition · R rename · ⌘K find · ⌘Z undo · ? keys · number row = intensity/bands. **Lint bans `window.addEventListener('keydown')` outside the hook.**

Selection is one shared hook (`useSelection`): single/multi, focus cursor, shift-range, auto-prune; selected = `fill-selected`, cursor = inset hairline ring. Reorder is one shared hook with the keyboard path built in.

---

## 6 · The editing feel (the Ink contract)

The system's crown jewel: **there is no edit mode.** One ProseMirror-backed primitive renders every text seat; reading and writing are the same visual state.

- **Three kinds, one contract:** `line` (single block, no marks — titles, names; Enter commits and blurs, because Enter belongs to GO) · `text` (multi-paragraph plain) · `prose` (markdown blocks: h2/h3, lists, quotes, bold/italic — shortcuts and ⌘B/⌘I free).
- **Butter is a latency contract.** Keystroke→paint is editor-uncontrolled — zero React renders per keystroke. Verify on a 2,000-word body with the profiler.
- **Continuous save.** Debounced ~600ms idle + flush on blur, tab-hide, and unmount; optimistic cache patch; no spinner, no saved-toast — *the words are simply kept*. Outside writes land only while the seat is idle (typing is never clobbered).
- **Esc reverts the current burst** (since the last save) — cancel without holding words hostage. **⌘Z is real** — editor history survives across saves because the editor never unmounts.
- **No chrome, ever.** No border, fill, or padding delta between reading and writing (the caret + the a11y focus voice only). No toolbar, no bubble menu, no slash menu — formatting rides markdown shortcuts and ⌘keys.
- **Everything is right-clickable.** Every seat joins the one menu engine; rename isn't a menu item (the name is just editable), so menus carry object verbs.
- **`[[` mentions.** In prose seats, `[[` queries the product's object index inline (quiet rows, accent-armed highlight); picking inserts a plain-text link token the saver resolves. ⚠️ *Engineering law learned in production:* suggestion-popup keys **must be handled inside the editor** (`editorProps.handleKeyDown` / a high-priority extension) — a React wrapper's `onKeyDown` runs after the editor's keymaps, so Enter will split paragraphs instead of picking.
- **Read-only is the same face** with the write paths absent — performance surfaces render the identical seat with no `onSave`.
- **The bar:** the squint test (screenshot editing vs reading — identical but the caret) · the keystroke test (indistinguishable from a bare textarea) · the crash test (kill the tab mid-word, lose ≤600ms of words) · the undo test · the menu test (no seat without a menu) · the one-feel test (a stranger can't tell any two seats were different components).
- **The boundary:** genuine configuration (URLs, keys, search queries) stays honest form fields. Ink is for the user's *words*.

---

## 7 · The drawer contract (the Sheet)

One drawer for "edit/inspect the thing selected from an adjacent list." **One presentation at every size:** the drawer **floats over** the region — scrim (stage/60, 1px blur), `shadow-stage`, radius-md, hairline — and the layout behind **never reflows**. (Push mode was built, shipped, and retired; don't rebuild it.) Under 700px container width it becomes a full takeover. Callers never branch; the region picks.

- **Anatomy:** 72px header band (title 15px semibold, optional accent tint at 10% from the armed object, header verbs) · scrollable body · optional resize handle on the leading edge.
- **A lens, not a dialog.** Arming a different object while open **swaps content in place** (120ms crossfade keyed on the object id). The drawer tracks the armed thing; it does not close/reopen.
- **Postures.** Lens (340px — exactly the adjacent column's width) vs focus (560px+, "room to write"), toggled in the header, remembered per surface key; the drag-handle width is remembered per key *per posture*. Esc steps focus → lens → closed.
- **The handle is a citizen:** `role="separator"`, focusable, ←/→ nudges 16px, drag pins to the pointer (width transition suspends while dragging), clamped 320–960px and to the region minus a 24px reveal.
- **Motion:** 150ms slide on the one ease; panel content rises 6px + fades starting 80ms in (arrives *with* the space, not after). Reduced motion: 120ms fade.
- **Focus & dismissal:** focus moves to the drawer title on open (mind the cold-mount ref-timing trap — focus after the panel exists); scrim-click, ✕, and Esc close; closing is announced with Undo when local state dies, never blocked by a dialog.
- **Touch:** ≥44px targets inside; safe-area insets in takeover.

**Decision rule:** blocking decision → Overlay. Editing an adjacent object → Sheet. A whole workflow → a route.

---

## 7.a · The console contract (the sticky control strip)

The persistent strip docked to the bottom edge of the app frame — Lorelight's "player," generalized: **not a media player but the app's hands.** Whatever your product's *running thing* is (playing audio, an active timer, a deploy in flight, a recording, a live broadcast), the console is where it stays visible and controllable from every surface, without navigating.

**The one-instrument law (non-negotiable).** The product has exactly ONE console. Bench posture and performance posture render *the same components reading the same store* — they may differ in density and in which verbs are present, never in implementation. Two strips that "look the same" but share no code will drift within a month; this law exists because it happened.

### Anatomy — six zones, left to right

```
┌──────────────────────────────────────────────────────────────────────────┐
│              (announcer line floats here, above the housing)             │
├──────────────────────────────────────────────────────────────────────────┤
│ [identity] [transport · COMMIT] [———— the board ————] [panic] [level ·⚑ ?]│
└──────────────────────────────────────────────────────────────────────────┘
```

1. **The identity cell** — what is running right now: name, one line of state, the accent glow (G7). Fixed width (~224px), **identity only — it never navigates while something is live.** Empty state speaks in voice ("Nothing playing — the room is quiet"), never blank. First zone to hide when width tightens (`hidden xl:block`).
2. **Transport** — the running thing's verbs: back, hold/resume (pressed state sits sunken), and on the performance posture the **commit key** (the GO family, §4 Tier 2) with the staged thing's name as sublabel. On the bench posture: play/pause, skip, loop — **skip is honest (G2): it walks a real queue or it sits dark.** Never a printed key that doesn't work.
3. **The board** — the flexible center: one-shot pads (or your equivalent quick actions). Compresses to fit before anything clips; only a window too small for the full set falls back to hidden-scrollbar horizontal scroll. `flex-1 min-w-0`, hairline borders both sides.
4. **The panic row** — the room-level toggles that must never be more than one glance away (duck/mute, environment toggle, STOP). Toggles wear `pressed` (sunken); **only the stop family wears the danger tone.**
5. **The level** — the master fader. Live `onChange` for feel, `onCommit` on release for persistence (§4 Slider law).
6. **Status + help** — the StatusDot(s) (G6: dot → click → detail overlay) and the `?` key.

If the product has **two channels** — a private preview bus (only the operator hears/sees it) and the public one (what the room gets) — both are visible here: the preview rises in its own small cell while active, and the public channel wears its duck state honestly beside the level. Never hide the private bus; the operator must always know what's about to hit the room.

### Housing physics

- `<footer>` at the app frame's bottom edge, full width, `z-rail`. **Docked, not floating:** flat `bg-surface`, `border-t border-hairline`, no shadow, no radius, no blur. (Floating chrome means Overlay/Sheet; the console is furniture.)
- Padding `px-4 py-3`; controls read `data-density` (performance: labeled 48px targets; bench: 32px icon controls, labels move into tooltips).
- **The announcer line (G5) mounts immediately above the housing** at `z-announce` — the console is the announcer's anchor on every surface.
- The strip never wraps to two rows on desktop. Zones yield in order: identity hides → board compresses → board scrolls.
- **Phone:** the console reduces to `[identity dot · COMMIT · panic]`, thumb-reachable, safe-area inset respected. The board and level fold behind a single expand control.
- Every zone-2/4 control is a `TransportButton` (icon · micro-label · printed key · pressed tone); scrubbing/seek, if the running thing has a timeline, drags locally and commits on release.

### Acceptance tests (agent checklist)

1. **The one-instrument test:** grep for the console's components — exactly one implementation, imported by every posture; the store is shared. A second "footer" component anywhere is a failing test.
2. **The glance test:** from any surface, one glance at the bottom edge answers "what is running, is it healthy, how do I stop it."
3. **The navigation test:** nothing in the console navigates away while something is live.
4. **The honesty test:** every printed key fires; every control that can't act sits dark rather than lying.
5. **The panic test:** STOP is reachable in one click/tap from every surface, wears danger, and never moves position between postures.
6. **The empty test:** with nothing running, the console still teaches ("Nothing playing — play something from the shelf"), in voice, and the commit key shows what's staged.

---

## 8 · Floating things & feedback

- **Menus** (§4) are the only floating actions surface; one engine, both dropdown and context modes, identical skin.
- **Overlays** never stack; `bare` variant for composed panels (help, pre-flight). Scrim stage/80 + 2px blur.
- **The announcer** is a fixed quiet line per surface region — state changes speak once, in voice, then fade. Undo lives in the line, not a button row.
- **Pre-flight, not error storms:** before a performance, one overlay checks every dependency and ticks results (G6 grouped); failures are named quietly ("That track would not play. The rest is unaffected.") and never block the rest.

---

## 9 · Copy voice

**The calm operator.** The interface speaks like the person backstage who has done this a thousand nights: announcements, not celebrations; domain verbs, not database verbs (strike, arm, GO, house lights — never delete/submit/execute where a stage word exists); contractions; no exclamation marks; no "successfully."

- All user-facing strings live in one copy module namespaced per surface; raw strings in feature components are lint-flagged.
- Registry components hold NO fixed words: every user-facing string is a prop with an English default (`closeLabel`, `resizeLabel`, `liveLabel`, `holdingLabel`, `dismissLabel`, the full `ariaLabel`/`tooltip` lines where word order varies by locale) — your copy module reaches every word without forking a component.
- Empty states teach the next verb ("Find a sound…"), errors name the thing and the recovery ("Struck *Crypt Descent*. Undo ⌘Z"), and nothing ever says "Oops."
- Eyebrows are places ("THE SHELF"), titles are objects, buttons are verbs.
- Copy is written per-device where verbs differ — never tell a phone to "press Enter."

---

## 10 · Accessibility laws

**Direction is logical.** The metaphor must survive RTL without a fork: components use logical properties and utilities (`ps`/`pe`, `ms`/`me`, `start-`/`end-`, `text-start`, `border-s/e`, `rounded-s/e`) — physical left/right is lint-banned (Appendix D); symmetric centering (`left-1/2` + translate) is direction-free and legal. Pointer math may stay physical, but its SIGN carries the writing direction (the Sheet's resize handle is the reference).

- Keyboard is a primary input, not a compliance layer: the binding map + generated help (§5) is the mechanism.
- One focus indicator per control: 2px accent outline for chrome; the caret + border warm-up for text seats; never both.
- Ink ladder is AA-checked against every surface token; placeholders use `--ink-faint` *at 4.5:1-checked sizes*.
- Every loop honors `prefers-reduced-motion` (breathe/pulse/flames → static; enters → fade).
- Icon-only controls carry Tooltip + aria-label (lint-enforced); dots carry text equivalents in their expansion.
- Touch: 44px minimums on any surface a thumb will reach; drag interactions always have a keyboard path (⌥↑/↓ reorder, ←/→ resize).

---

## 11 · Governance (why this system is still alive)

The predecessor system died of *governance failure*, not design failure — components existed and nothing made anyone use them. This one polices itself:

1. **The living catalog** (`/design-system`, public): every component rendered from its real source with variants, states, keyboard map, and *Use when / Don't use when*. A component isn't done until its entry exists. **The catalog is the contract — when a ruling changes a contract, the catalog copy changes in the same PR** (drift here is a P2 bug, found in practice).
2. **Lint guards as errors,** not conventions: no raw hexes in components; no `white/*`, `black/*`, `neutral-*` utilities; no `title=`; no Radix imports outside `ui/`; no bare keydown listeners; no `fixed inset-0` outside the overlay module.
3. **The decision tree for new UI** (PR checklist): (1) an existing component does this → use it; (2) it's a variant → add the variant + catalog entry, never inline; (3) genuinely first-of-its-kind → build it in the system's home with a catalog entry and a grammar citation (which G-rule it serves) in the same PR; (4) ≤10 lines of layout glue with zero styling decisions → inline is fine.
4. **The variability policy:** components expose variants (closed set), ambient density, copy via props, and slots. **Never `className` passthroughs for color/spacing** — that's how drift starts. The escape hatch is filing a variant.

---

## 12 · Adoption playbook (a different market, step by step)

Worked example — say the adopter is **"Segue," a live-podcast control room** (any performance-adjacent tool maps the same way; even a plain SaaS tool has a "showtime" — the demo, the launch, the review meeting).

1. **Name the metaphor and the two postures.** Theater → broadcast booth. Bench = "the desk" (prep: rundowns, clips, notes). Stage = "on air."
2. **Map the vocabulary** (this table is the whole rebrand):

| System concept | Lorelight | Segue |
|---|---|---|
| The performance surface | Tonight / the stage | On Air |
| The unit you commit | Scene | Segment |
| The commit control | GO | TAKE |
| Staged state | Armed | Cued |
| Live state color | patina | on-air red-amber |
| One-shots (pads) | Thunder/Door/Sting | Stinger/Bumper/SFX |
| Intensity bands | Ambient→Resolve | Energy 1–4 |
| Destructive verb | Strike | Kill |
| The transient line | The announcer | Talkback |
| Pre-flight | Sound check | Line check |
| The archive room | The shelf | The library |
| The console (sticky strip) | The player / the Rail | The board |
| The running thing (identity cell) | Now playing | On air now |
| The panic row | Duck · House · Stop | Cough · Cart wall · Kill |

3. **Retune the accent** (§2.9): brass → e.g. tungsten amber; keep the triad structure and all its jobs.
4. **Copy the token block, the primitives, and the shells verbatim.** Rename exports per the table. Do not "improve" values in transit.
5. **Write the copy module in the new voice first** — the calm operator translated to the new room. Voice drift is the most common adoption failure; the words carry the system as much as the tokens.
6. **Stand up the living catalog and the lint guards before the second surface exists.** Governance is step zero, not polish — adopt the guards list in §11.2 wholesale on day one.
7. **Build the performance surface first.** The instrument grammar must be proven where attention is scarcest; the bench then inherits its anatomy (RoomShell law). Building the CRUD screens first is how you end up with a dashboard wearing a costume.
8. **Adopt the bars as acceptance tests:** Ink's six tests (§6), the drawer contract (§7), the G-rules as PR citations, and the product slop test — *would a user fluent in the category's best tools trust every control at first sight?*

---

## Appendix A — the paste-ready token sheet

**The contract metrics ride along as importable constants.** The behavioral contracts' numbers — sheet takeover 700 / clamps 320–960 / lens 340 / focus 560 / header 72 / crossfade 120 (§7), modal 460/640/bare 560 (§8), strip 76 (§4), save debounce 600 (§6), hold 600 (§4), announcer 5500 / toast 6000 (§8) — have ONE home: a JS metrics module shipped beside the token sheet (`@candlelight-ds/tokens/metrics` in the reference implementation). Components import them, never re-declare them; the package's law tests pin the values.

```css
:root {
  /* stage */
  --stage:#0a0908; --surface:#141210; --surface-2:#1b1814; --surface-3:#242019;
  --hairline:rgba(241,231,211,.1); --hairline-strong:rgba(241,231,211,.18);
  /* ink */
  --ink:#f8f3eb; --ink-mute:#c9bfae; --ink-faint:#8f8779;
  /* voice — retune hues per §2.9, keep the jobs */
  --brass:#d8b074; --brass-hot:#e4c18e; --brass-deep:#a8823f;
  --live:#74c2bd; --ready:#9bb97f; --warn:#d9a03f; --danger:#e5484d; --ember:#c96342;
  /* fill ramp — never hand-mix alphas */
  --fill-rest:rgba(241,231,211,.03); --fill-hover:rgba(241,231,211,.05);
  --fill-active:rgba(241,231,211,.08); --fill-selected:rgba(216,176,116,.09);
  /* tint ramp — voice-color washes are one of these four stops (§2.4a) */
  --tint-fill:10%; --tint-chip:12%; --tint-held:15%; --tint-rim:45%;
  /* the baseline grid (§2.6a) — every leading/height a whole multiple */
  --baseline:4px;
  /* structure */
  --radius-sm:8px; --radius-md:16px; --radius-lg:24px;
  --shadow-raise:0 8px 28px rgba(0,0,0,.28);
  --shadow-float:0 16px 50px rgba(0,0,0,.45);
  --shadow-stage:0 40px 120px rgba(0,0,0,.6);
  --z-surface:0; --z-rail:10; --z-float:30; --z-overlay:50; --z-announce:60;
  /* motion */
  --dur-instant:120ms; --dur-ui:150ms; --dur-sheet:150ms; --dur-scene:2500ms;
  --ease-stage:cubic-bezier(.22,1,.36,1);
  /* physicality */
  --edge-machined:inset 0 1px 0 rgba(255,255,255,.16), inset 0 -1px 0 rgba(0,0,0,.3);
  --edge-sunken:inset 0 2px 4px rgba(0,0,0,.45);
}
```

---

## Appendix B — Tailwind v4 theme mapping

The tokens become utilities via `@theme inline` — `bg-stage`, `text-ink-mute`, `border-hairline`, `bg-fill-hover`, `shadow-float`, etc. The utility *names* are the system's API; if you use another styling stack, reproduce the names.

```css
@import "tailwindcss";

@theme inline {
  --color-stage: var(--stage);
  --color-surface: var(--surface);
  --color-surface-2: var(--surface-2);
  --color-surface-3: var(--surface-3);
  --color-hairline: var(--hairline);
  --color-hairline-strong: var(--hairline-strong);
  --color-ink: var(--ink);
  --color-ink-mute: var(--ink-mute);
  --color-ink-faint: var(--ink-faint);
  --color-brass: var(--brass);          /* rename to your accent's word */
  --color-brass-hot: var(--brass-hot);
  --color-brass-deep: var(--brass-deep);
  --color-live: var(--live);
  --color-ready: var(--ready);
  --color-warn: var(--warn);
  --color-danger: var(--danger);
  --color-ember: var(--ember);
  --color-fill-rest: var(--fill-rest);
  --color-fill-hover: var(--fill-hover);
  --color-fill-active: var(--fill-active);
  --color-fill-selected: var(--fill-selected);

  /* the tint ramp (§2.4a): voice × stop, a closed set —
     bg-live-chip, ring-brass-rim, border-danger-rim, bg-brass-held, … */
  --color-brass-fill: color-mix(in srgb, var(--brass) var(--tint-fill), transparent);
  --color-brass-chip: color-mix(in srgb, var(--brass) var(--tint-chip), transparent);
  --color-brass-held: color-mix(in srgb, var(--brass) var(--tint-held), transparent);
  --color-brass-rim: color-mix(in srgb, var(--brass) var(--tint-rim), transparent);
  --color-live-fill: color-mix(in srgb, var(--live) var(--tint-fill), transparent);
  --color-live-chip: color-mix(in srgb, var(--live) var(--tint-chip), transparent);
  --color-live-held: color-mix(in srgb, var(--live) var(--tint-held), transparent);
  --color-live-rim: color-mix(in srgb, var(--live) var(--tint-rim), transparent);
  --color-ready-fill: color-mix(in srgb, var(--ready) var(--tint-fill), transparent);
  --color-ready-chip: color-mix(in srgb, var(--ready) var(--tint-chip), transparent);
  --color-ready-held: color-mix(in srgb, var(--ready) var(--tint-held), transparent);
  --color-ready-rim: color-mix(in srgb, var(--ready) var(--tint-rim), transparent);
  --color-warn-fill: color-mix(in srgb, var(--warn) var(--tint-fill), transparent);
  --color-warn-chip: color-mix(in srgb, var(--warn) var(--tint-chip), transparent);
  --color-warn-held: color-mix(in srgb, var(--warn) var(--tint-held), transparent);
  --color-warn-rim: color-mix(in srgb, var(--warn) var(--tint-rim), transparent);
  --color-danger-fill: color-mix(in srgb, var(--danger) var(--tint-fill), transparent);
  --color-danger-chip: color-mix(in srgb, var(--danger) var(--tint-chip), transparent);
  --color-danger-held: color-mix(in srgb, var(--danger) var(--tint-held), transparent);
  --color-danger-rim: color-mix(in srgb, var(--danger) var(--tint-rim), transparent);

  --shadow-raise: 0 8px 28px rgba(0, 0, 0, 0.28);
  --shadow-float: 0 16px 50px rgba(0, 0, 0, 0.45);
  --shadow-stage: 0 40px 120px rgba(0, 0, 0, 0.6);
  --font-sans: var(--font-inter);
}

body { background: var(--stage); color: var(--ink);
       font-family: var(--font-inter), system-ui, sans-serif; }
```

---

## Appendix C — structural & motion CSS (the physics)

The part an agent cannot infer from prose. Paste below the tokens; class names are normative.

Cascade law: every tinted wash below derives from a voice/ink token via `color-mix` — never a raw rgba of a token's hue. Retuning `--brass` (the ONE sanctioned knob, §2.9) must reach the physics without editing this block.

```css
/* Focus — keyboard is a primary input. ONE indicator per control:
   text seats carry theirs via border/caret, so the ring stands down there. */
:focus-visible { outline: 2px solid var(--brass); outline-offset: 2px; border-radius: 4px; }
input:focus-visible, textarea:focus-visible,
[contenteditable]:focus-visible, .outline-none:focus-visible { outline: none; }

button, a, [role="button"] { cursor: pointer; }

/* Enter = rise 6px + fade. Exit = fade only (never slide out). */
@keyframes stage-rise { from { opacity: 0; transform: translateY(6px); }
                        to   { opacity: 1; transform: translateY(0); } }
.animate-stage-rise { animation: stage-rise var(--dur-ui) var(--ease-stage) both; }
@keyframes scale-in { from { transform: scale(0.9); opacity: 0; }
                      to   { transform: scale(1);   opacity: 1; } }
.animate-scale-in { animation: scale-in 0.2s var(--ease-stage); }

/* Skeleton — a flat fill breathing. No shimmer wash. */
@keyframes skeleton-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.55; } }
.skeleton { background: var(--fill-hover); animation: skeleton-pulse 1.8s ease-in-out infinite; }

/* The drawer panel — rises in 80ms after the slide starts (arrives WITH the
   space); width animates for posture toggles but pins to the pointer mid-drag. */
.sheet-panel { opacity: 0; transform: translateY(6px);
  transition: opacity calc(var(--dur-sheet) - 80ms) var(--ease-stage) 80ms,
              transform calc(var(--dur-sheet) - 80ms) var(--ease-stage) 80ms,
              width 200ms var(--ease-stage); }
.sheet-panel[data-state='open'] { opacity: 1; transform: translateY(0); }
.sheet-panel[data-dragging='true'] { transition-property: opacity, transform; }

/* The commit key (GO family): dark key, strong accent rim, machined edge.
   Armed pulses the rim's TEMPERATURE — a color pulse, never a halo. */
.key-cue { border: 1.5px solid var(--brass); box-shadow: var(--edge-machined);
  transition: border-color var(--dur-ui) var(--ease-stage),
              background-color var(--dur-ui) var(--ease-stage),
              transform var(--dur-instant) var(--ease-stage); }
.key-cue:hover:not(:disabled) { border-color: var(--brass-hot);
  background-color: color-mix(in srgb, var(--brass) 14%, transparent); }
.key-cue:active:not(:disabled) { transform: translateY(1px); }
@keyframes cue-armed { 0%, 100% { border-color: var(--brass); }
                       50%      { border-color: var(--brass-hot); } }
.key-cue-armed:not(:active):not(:disabled) { animation: cue-armed 2.8s var(--ease-stage) infinite; }

/* Solid-accent primaries: machined cap, 1px of real travel. */
.key-brass { box-shadow: var(--edge-machined);
  transition: background-color var(--dur-ui) var(--ease-stage),
              transform var(--dur-instant) var(--ease-stage); }
.key-brass:active:not(:disabled) { transform: translateY(1px); }
.key-sunken { box-shadow: var(--edge-sunken); }  /* a held/toggled key sits down */

/* One-shot pad strike: squish + accent bloom decaying like a drum-pad LED.
   Restartable: remove class → force reflow → re-add. */
@keyframes pad-hit {
  0%   { transform: scale(0.94);
         background-color: color-mix(in srgb, var(--brass) 26%, transparent);
         box-shadow: inset 0 0 16px color-mix(in srgb, var(--brass) 40%, transparent),
                     0 2px 14px color-mix(in srgb, var(--brass) 30%, transparent); }
  40%  { transform: scale(1); }
  100% { transform: scale(1);
         background-color: color-mix(in srgb, var(--brass) 0%, transparent);
         box-shadow: inset 0 0 0 color-mix(in srgb, var(--brass) 0%, transparent),
                     0 0 0 color-mix(in srgb, var(--brass) 0%, transparent); } }
.pad-hit { animation: pad-hit 340ms var(--ease-stage); }

/* Ambient identity light (C4/G7): two radial washes tinted by the live thing. */
.room-glow-layer { background:
    radial-gradient(ellipse 90% 65% at 50% -10%,
      var(--room-glow, color-mix(in srgb, var(--brass) 16%, transparent)), transparent 70%),
    radial-gradient(ellipse 60% 50% at 85% 110%,
      var(--room-glow-secondary, color-mix(in srgb, var(--live) 7%, transparent)), transparent 65%);
  transition: opacity 2.5s var(--ease-stage); }
@keyframes room-breathe { 0%, 100% { opacity: 0.85; } 50% { opacity: 1; } }
.room-glow-breathe { animation: room-breathe 9s ease-in-out infinite; }

/* Range slider: flat two-zone progress (accent → hairline), hard stop. */
input[type="range"].slider { -webkit-appearance: none; appearance: none;
  border-radius: 9999px;
  background: linear-gradient(to right, var(--brass) 0%,
    var(--brass) var(--slider-progress, 50%),
    var(--hairline) var(--slider-progress, 50%)); }
input[type="range"].slider::-webkit-slider-runnable-track { background: transparent; height: 8px; }
input[type="range"].slider::-webkit-slider-thumb { -webkit-appearance: none; appearance: none;
  width: 12px; height: 12px; margin-top: -2px; /* center 12px thumb on 8px track */
  border-radius: 50%; background: var(--ink);
  border: 1px solid color-mix(in srgb, var(--ink) 30%, transparent); }

/* Checkbox: 14px, accent fill when checked. */
input[type="checkbox"] { appearance: none; width: 14px; height: 14px;
  border: 1.5px solid color-mix(in srgb, var(--ink) 30%, transparent);
  border-radius: 3px; background: transparent;
  transition: border-color 150ms ease, background-color 150ms ease; position: relative; }
input[type="checkbox"]:checked { background: var(--brass); border-color: var(--brass); }
input[type="checkbox"]:checked::after { content: ''; position: absolute;
  left: 3.5px; top: 1px; width: 4px; height: 7px;
  border: solid var(--surface); border-width: 0 2px 2px 0; transform: rotate(45deg); }

/* The baseline overlay (§2.6a) — governance made visible: one line per
   unit, ONE weight (the unit is the law; a heavier "major" implies an
   editorial baseline the system never promised). It MUST live INSIDE
   the surface it audits — same content box — or the lines lie.
   Toggleable proof, never decoration. */
.baseline-overlay { position: absolute; inset: 0; pointer-events: none;
  background:
    repeating-linear-gradient(to bottom,
      color-mix(in srgb, var(--live) 16%, transparent) 0 1px,
      transparent 1px var(--baseline)); }
/* Square mode: the horizontal rhythm joins (spacing shares the unit). */
.baseline-overlay[data-square] { background:
    repeating-linear-gradient(to bottom,
      color-mix(in srgb, var(--live) 16%, transparent) 0 1px,
      transparent 1px var(--baseline)),
    repeating-linear-gradient(to right,
      color-mix(in srgb, var(--live) 10%, transparent) 0 1px,
      transparent 1px var(--baseline)); }

/* The accusation: elements the overlay's measuring pass finds off the
   baseline. Verification wears danger; alignment stays quiet. */
[data-off-baseline] { outline: 1px dashed var(--danger); outline-offset: 0; }

/* Scrollbars: thin, hairline, quiet. */
.scrollbar-custom { scrollbar-width: thin;
  scrollbar-color: color-mix(in srgb, var(--ink) 12%, transparent) transparent; }
.scrollbar-custom::-webkit-scrollbar { width: 8px; }
.scrollbar-custom::-webkit-scrollbar-track { background: transparent; }
.scrollbar-custom::-webkit-scrollbar-thumb {
  background: color-mix(in srgb, var(--ink) 12%, transparent); border-radius: 4px; }

/* Every loop dies under reduced motion; one-shots become instant. */
@media (prefers-reduced-motion: reduce) {
  .skeleton, .room-glow-breathe, .key-cue-armed:not(:active):not(:disabled),
  .pad-hit { animation: none; }
  .key-cue-armed:not(:active):not(:disabled) { border-color: var(--brass-hot); }
  .sheet-panel { transition: opacity 120ms ease; transform: none; }
}
```

---

## Appendix D — the lint guards (ESLint flat config)

Governance as code. Two gotchas learned in production: (1) in flat config, a later block configuring `no-restricted-syntax` **replaces** the earlier block's selectors wholesale for files both match — share selectors via a spread constant in every block; (2) esquery regex literals terminate at the first `/` even escaped — match slashes with `/`.

```js
// eslint.config.mjs (flat config) — the design-system guards
const gradientBan = [
  { selector: "Literal[value=/gradient\\(/]",
    message: "No gradients as identity — place reads through accents and words." },
  { selector: "TemplateElement[value.raw=/gradient\\(/]",
    message: "No gradients as identity." },
  { selector: "JSXAttribute[name.name='className'] Literal[value=/bg-gradient/]",
    message: "No gradient utilities." },
  { selector: "JSXAttribute[name.name='className'] TemplateElement[value.raw=/bg-gradient/]",
    message: "No gradient utilities." },
];

export default [
  // Radix (or your primitive lib) reaches the app only through ui/ wrappers.
  { files: ["components/**/*.tsx", "app/**/*.tsx", "hooks/**/*.{ts,tsx}"],
    ignores: ["components/ui/**"],
    rules: { "no-restricted-imports": ["error", { patterns: [
      { group: ["@radix-ui/*"],
        message: "Radix is wrapped — import from components/ui instead." }]}] } },

  // Token discipline: no hand-mixed alphas, no raw grays, no gradients.
  { files: ["components/**/*.{ts,tsx}", "app/**/*.{ts,tsx}"],
    rules: {
      "no-restricted-syntax": ["error",
        { selector: "JSXAttribute[name.name='className'] Literal[value=/white\\u002F/]",
          message: "Use the fill ramp / ink tokens, not white/* alphas." },
        { selector: "JSXAttribute[name.name='className'] TemplateElement[value.raw=/white\\u002F/]",
          message: "Use the fill ramp / ink tokens, not white/* alphas." },
        { selector: "JSXAttribute[name.name='className'] Literal[value=/bg-black/]",
          message: "Use bg-stage (or stage alphas), not bg-black." },
        { selector: "JSXAttribute[name.name='className'] TemplateElement[value.raw=/bg-black/]",
          message: "Use bg-stage (or stage alphas), not bg-black." },
        { selector: "JSXAttribute[name.name='className'] Literal[value=/neutral-[0-9]/]",
          message: "Use the system tokens, not neutral-* grays." },
        { selector: "JSXAttribute[name.name='className'] TemplateElement[value.raw=/neutral-[0-9]/]",
          message: "Use the system tokens, not neutral-* grays." },
        // The tint ramp is closed (§2.4a): voice alphas ≤50 have names.
        { selector: "JSXAttribute[name.name='className'] Literal[value=/-(brass(-hot|-deep)?|live|ready|warn|danger|ember)\\u002F([1-9]|[1-4][0-9]|50)\\b/]",
          message: "Voice-color washes are the named tint ramp — *-fill/-chip/-held/-rim (§2.4a)." },
        { selector: "JSXAttribute[name.name='className'] TemplateElement[value.raw=/-(brass(-hot|-deep)?|live|ready|warn|danger|ember)\\u002F([1-9]|[1-4][0-9]|50)\\b/]",
          message: "Voice-color washes are the named tint ramp — *-fill/-chip/-held/-rim (§2.4a)." },
        ...gradientBan],
      "react/forbid-dom-props": ["error", { forbid: [
        { propName: "title",
          message: "title= is banned — wrap the control in ui/Tooltip." }]}] } },

  // Floating UI mounts through Overlay/Sheet only.
  { files: ["components/**/*.tsx"], ignores: ["components/ui/**"],
    rules: { "no-restricted-syntax": ["error",
      { selector: "JSXAttribute[name.name='className'] Literal[value=/fixed inset-0/]",
        message: "Use ui/Overlay or ui/Sheet — bespoke fixed-inset shells are banned." },
      { selector: "JSXAttribute[name.name='className'] TemplateElement[value.raw=/fixed inset-0/]",
        message: "Use ui/Overlay or ui/Sheet — bespoke fixed-inset shells are banned." },
      ...gradientBan] } },
];
```

Two guards need conventions rather than selectors: bare `window.addEventListener('keydown')` outside the surface-keyboard hook, and raw user-facing strings outside the copy module — enforce those in review until you write custom rules.
