# Design System — design.md

## Overview

This design system is a **cool-professional, teal-anchored** component library and documentation website. The base atmosphere is a **soft gray page floor** (`--color-bg-page-primary` — #F1F1F1) carrying **crisp white containers** (`--color-bg-container-primary` — #FFFFFF) that step deeper through the neutral hierarchy (soft gray #F1F1F1 → mid gray #D6D6D6) — clinical, precise, never warm. The brand voltage comes from **signature teal** (`--color-action-primary-bg` — #0E6E8F light / #3CA5C6 dark), a cyan-blue that reads trustworthy and technical without corporate-blue flatness. The action colour is deliberately theme-dependent: no single teal step can clear 3:1 against both a light page and a near-black one while also carrying a 4.5:1 label, so light mode takes a deep fill under a light label and dark mode inverts to a light fill under a dark label.

The system runs a **single typeface throughout**: **Nunito Sans** at weight 300 (display/hero) → 600 (headings) → 500/400 (body/UI), with one site-chrome exception — the header wordmark sits at 700, one step above title-body's 600, so the name reads heavier than the nav pills beside it (the Site header pattern owns the detail). There is no serif face, and monospace appears only in sanctioned code contexts through `--font-family-code` — the typographic personality is clean, rounded, and approachable rather than editorial. The single face is this theme's choice, not a structural limit: the scale chains through two family roles (`--font-family-heading` for the display and heading tiers, `--font-family-body` for the body tiers), both resolving to `--font-family-primary` here, so a consumer can split heading and body faces by overriding the roles (see Font Family below).

The **three-tier token architecture** is the defining structural rule:
1. **Primitives** (`--primitive-*`) — raw hex/px values. Source of truth. Never used directly in components.
2. **Semantic tokens** (`--color-*`, `--radius-*`, `--gap-*`, `--padding-*`, `--border-*`, `--font-*`, `--motion-*`, `--icon-size-*`, `--shadow-*` — `CATEGORY_PREFIXES` in `scripts/generate-token-registry.mjs` is the authoritative list) — usage-intent variables consumed by components. Always use these.
3. **Component CSS classes** (`.ds-button`, `.ds-badge`, etc.) — per-component scope, referencing semantic tokens.

The system is **light/dark-first**: every semantic color token has a light-theme value and a dark-theme override. The switch is driven by `data-theme="dark"` on the root element. Status colors (positive, warning, error, info) stay perceptually stable across themes; surfaces and text invert.

**Key Characteristics:**
- Soft gray page floor (`--color-bg-page-primary` — #F1F1F1) with near-black primary text (`--color-text-primary` — #050505 light / #F1F1F1 dark), and crisp white containers lifting off it.
- Teal primary action (`--color-action-primary-bg` — #0E6E8F light / #3CA5C6 dark). Used exclusively on primary CTA buttons, focus rings, and the selected item of a mutually exclusive set (the teal selection convention: SegmentedControl's active segment, Pagination's current page, Stepper's active step, the site header's current-section pill), with one sanctioned data-viz exception: teal leads the default chart series palette. Never decorative elsewhere.
- Single-family system (Nunito Sans in the shipped theme). Weight 300 for Mega/Display (marketing), 600 for headings, 500/400 for body and UI labels; 700 appears once, on the site header's wordmark.
- Container hierarchy as depth signal — standard containers carry no drop shadows. Depth is conveyed by stepping through `--color-bg-container-primary` → `secondary` → `tertiary`; the only shadows are the `--shadow-floating`/`--shadow-modal` tokens on floating surfaces and the interactive-card hover lift.
- Five semantic status variants running through every feedback component: `info` (blue), `positive` (green), `warning` (orange), `error` (red), `neutral` (gray).
- Border radius is hierarchical: `--radius-100` (4px) for badges, `--radius-300` (12px) for inputs and standard containers, `--radius-600` (24px) for the sanctioned soft-container set the Border Radius Scale table below enumerates (navigation tiles, chat surfaces, Panel, the floating sidebar), `--radius-pill` (999px) for buttons.
- Material Symbols Rounded as the shipped icon set (a theme choice, not a structural limit — see the icon style hooks and the node-accepting icon props), on a four-step size scale — `--icon-size-500` (20px) / `md` (24px, default) / `lg` (32px) / `xl` (48px). Optical size tracks the step automatically.
- One GPU-rendered surface: **ShaderField**, an ambient field of soft light sources that sample the colour tokens at runtime, so it re-themes with everything else. It is scenery rather than interface, it reports how it resolved so a caller can keep a fallback painted, and its `prefers-reduced-motion` check lives in JavaScript, where the CSS guard cannot reach (Carousel's autoplay gate makes the same move).
- Accessibility-first: ARIA roles, semantic HTML, and keyboard navigation in every interactive component.

### Preset themes

The playground's saved looks are shippable build artifacts, not demos. `THEME_PRESETS` in `website/src/lib/theme/presets.ts` is the registry of complete themes — every lever holds a position, never inherits one: the action brand (with an optional theme-dependent `brandDark`), neutral tint, radius scale and pill switch, spacing `density`, `typeScale`, `motionScale`, the `elevation` variant, the body and heading typefaces, the ambient accent sextet (`accents`), hand-tuned ramp re-keys (`advanced`), and per-preset extras — colour corrections with dark-only variants where the derived ramp's shape inverts, but any token is fair game (Forest re-pitches the display-tier weight tokens for its heavier display face). The ambient accent sextet is part of every theme's declaration: the six `--color-core-accent-*` roles ship as hexes per preset and drive the background blobs and chart series 2–7 together. One pure composer, `presetOverrides(preset, theme)`, turns a preset into its exact override map; the playground's live preview and the generated stylesheets both call it, so the preview and the shipped theme cannot disagree.

`scripts/generate-preset-stylesheets.mjs` writes each preset to `src/tokens/presets/<id>.css` — an `html[data-brand="<id>"]` block carrying the light declarations and an `html[data-brand="<id>"][data-theme="dark"]` block carrying the dark ones, emitted even when identical so the cascade is self-evident — plus a `presets.css` aggregate that only imports them all. The files ship in the npm package under the `tokens/presets/` subpath. The contract: import `tokens/presets/presets.css` after `tokens/tokens.css`, set one `data-brand` attribute on `<html>`, and the whole app rethemes end to end — light and dark included, zero runtime JavaScript. Specificity does the work: `html[data-brand]` outranks the token files' `:root` and `[data-theme]` blocks, and the preset's dark block outranks its light one. The documentation website loads the bundle from its root layout, so any page rethemes the moment the attribute is set.

Two build gates guard the surface. `scripts/validate-preset-stylesheets.mjs` regenerates in memory and byte-compares every file, holds the folder to `THEME_PRESETS` in both directions, and parses one preset's CSS back into declarations to prove it equals the composer's output. `scripts/validate-theme-presets.mjs` is the completeness gate — the "one attribute, full theme" guarantee: every override name must resolve to a real token or primitive, the action family must be overridden or provably intended-default (the brand equals `DEFAULT_BRAND`, which the action lever short-circuits to the shipped token files), the resolved action-primary bg/text pairing must hold WCAG AA 4.5:1 in both themes (a preset that deliberately cannot would be pinned in the script's `SANCTIONED_AA_GAPS` with its ratio and reason, never silently passed — the map is empty today, every shipped preset holds AA), and the density/type/motion/elevation levers must be present on every preset — as must all six ambient accent roles, in both themes, with no intended-default exemption.

---

## Colors

### Token Tiers

Never reference `--primitive-*` tokens inside components. Always use the semantic layer (`--color-*`). The chain is enforced in the other direction too: every semantic colour token's value must be a `var(--primitive-*)` reference — never a raw hex/rgba literal — so that overriding a primitive re-themes everything built on it (`scripts/validate-token-references.mjs` fails the build otherwise). The hex values noted throughout this section are the resolved values of those primitives.

The primitive colour scales share one shape. The neutral scale and the seven chromatic ramps (red, orange, yellow, green, teal, blue, purple) all run **00–11**, light to dark — relative luminance decreases strictly down every chromatic ramp, build-enforced by `scripts/validate-theme-mirrors.mjs`. Step 07 is each chromatic ramp's hand-drawn key colour; the 00 and 11 end caps are generated tint/shade extensions (the blend method and factors are documented above the ramps in `tokens-primitives.css`). Translucent neutral variants follow the **`-aNN` grammar** — NN is the alpha × 100, so `--primitive-neutral-09-a80` is neutral-09 at 80% opacity — and the suffix is held to the rgba() value's actual alpha by `scripts/validate-token-references.mjs`.

### Action / Brand

The action roles are the one token family that splits per theme by design (see Overview): the primary roles run a deep light-mode fill under a light label and invert for dark (the neutral roles stay grey-on-grey in each theme), and hover/active walk away from the page — deepening in light, brightening in dark.

- **Primary bg** (`--color-action-primary-bg` — #0E6E8F light / #3CA5C6 dark): Teal. Every primary CTA button fill, focus rings, active input borders, and selected-state fills under the teal selection convention. The most-recognized brand color.
- **Primary bg hover** (`--color-action-primary-bg-hover` — #0A4E66 light / #6DBCD6 dark): Hover shift on primary buttons.
- **Primary bg active** (`--color-action-primary-bg-active` — #052F3E light / #9ED4E5 dark): Press/active shift on primary buttons.
- **Primary text** (`--color-action-primary-text` — #CFEAF3 light / #052F3E dark): Text/icon color on the primary fill — light teal on the deep light-mode fill, deep teal on the light dark-mode fill.
- **Primary text active** (`--color-action-primary-text-active` — #F1F1F1 light / #052F3E dark): Text on hovered/active primary button.
- **Primary border** (`--color-action-primary-border` — #052F3E light / #3CA5C6 dark): Outline on secondary (outlined) Buttons and CircularButtons.
- **Secondary border** (`--color-action-primary-border-secondary` — #0E6E8F light / #2C9AB9 dark): Brighter border step — the completed-step border and connector in Stepper.
- **Tertiary border** (`--color-action-primary-border-tertiary` — #0E6E8F light / #6DBCD6 dark): Hover ring on RadioButton.
- **Tertiary text** (`--color-action-primary-text-tertiary` — #0A4E66 light / #6DBCD6 dark): Teal-coloured text for tertiary/ghost button labels.
- **Passive bg** (`--color-action-passive-bg` — rgba(241,241,241,0.01)): Near-transparent ghost button fill.
- **Passive bg hover** (`--color-action-passive-bg-hover` — rgba(214,214,214,0.8)): Gray hover on ghost/tertiary buttons.
- **Passive text** (`--color-action-passive-text` — #050505 light / #F1F1F1 dark): Ghost button label color.
- **Neutral bg** (`--color-action-neutral-bg` — #D6D6D6 light / #232323 dark): Solid grey fill for the `neutral` button variants — unlike passive, the background is always shown at rest.
- **Neutral bg hover** (`--color-action-neutral-bg-hover` — #BCBCBC light / #303030 dark): Hover shift on neutral buttons — deepening in light, brightening in dark, like the primary family.
- **Neutral bg active** (`--color-action-neutral-bg-active` — #A2A2A2 light / #6D6D6D dark): Press/active shift on neutral buttons.
- **Neutral text** (`--color-action-neutral-text` — #050505 light / #F1F1F1 dark): Text/icon color on the neutral fill.

### Surfaces
- **Page primary** (`--color-bg-page-primary` — #F1F1F1 light / #050505 dark): The floor of every screen.
- **Container primary** (`--color-bg-container-primary` — #FFFFFF light / rgba(14,14,14,0.8) dark): First elevation above page — sidebars, section bands, card fills. In light mode this is crisp white lifting off the gray page floor.
- **Container secondary** (`--color-bg-container-secondary` — #F1F1F1 light / #303030 dark): Second elevation — nested containers, divider fills.
- **Container tertiary** (`--color-bg-container-tertiary` — #D6D6D6 light / #232323 dark): Third elevation — pressed states, control tracks (Slider, ProgressBar, Meter, Spinner, Gauge), deepest nesting.
- **Container border** (`--color-bg-container-border` — #D6D6D6 light / #232323 dark): Hairline borders on containers.
- **Container inverse** (`--color-bg-container-inverse` — #0E0E0E light / #F1F1F1 dark): High-contrast inverted surface — ImageCompare's handle label, the calendar and Gantt "today" markers. Always paired with `--color-text-on-inverse`.
- **Glass** (`--color-bg-glass` — rgba(255,255,255,0.9) light / rgba(14,14,14,0.66) dark): The floating-surface fill — deliberately translucent in both themes so the page's colours reach the surface. Always paired with `backdrop-filter: blur(24px)`, which is what keeps content readable through it; the blur radius is a documented constant, not a token. Used by the site chat panel, AppSidebar's floating variant, CommandPalette's panel, and the chart family's tooltips.
- **Divider** (`--color-divider` — rgba(214,214,214,0.8) light / rgba(35,35,35,0.8) dark): Horizontal/vertical rule between sections.

### Chat surfaces
Chat surfaces get their own colour roles so they can be re-themed without touching the container ramp. The four bubble roles currently alias the container-secondary / text-primary pair — deliberately identical today, separable later; the context fill is its own tint.
- **Sent bg** (`--color-chat-bubble-sent-bg` → `--color-bg-container-secondary`): Fill of the person's outgoing bubble.
- **Sent text** (`--color-chat-bubble-sent-text` → `--color-text-primary`): Text inside the sent bubble.
- **Received bg** (`--color-chat-bubble-received-bg` → `--color-bg-container-secondary`): Fill of a received bubble, when the agent's turn renders as a bubble at all.
- **Received text** (`--color-chat-bubble-received-text` → `--color-text-primary`): Text inside the received bubble.
- **Context bg** (`--color-chat-context-bg` → `--primitive-neutral-01` light / `--primitive-neutral-09` dark): Fill of the Composer's context note — the quietest tint per theme, the nearest neutral to each shell that still separates from it. Its own role rather than a container alias because it deliberately sits off the container ramp.

ChatMessage consumes only the four bubble roles above — never the container tokens directly. Teal is never a bubble fill; the action colour keeps its CTA meaning.

### Calendar surfaces
EventCalendar's day cells and event pills get their own colour roles, for the same reason chat surfaces do — tunable without touching the container ramp. The family exists because the dark container ramp's steps are too far apart for calendar density: a `#303030` cell field glares against the near-black calendar container, and a hover that lands between the cell and pill fills swallows both. Cells rest one quiet step off the container and step one gentle notch away from the pill fill on hover — lighter in dark, darker in light; a hovered cell keeps its hover while the pointer is on a pill inside it, so the pill's own hover fill and hover-only border ring are what separate it.
- **Cell bg** (`--color-calendar-cell-bg` — `--primitive-neutral-01` light / `--primitive-neutral-08-a80` dark): Resting fill of an in-month day cell.
- **Cell bg hover** (`--color-calendar-cell-bg-hover` — `--primitive-neutral-02-a80` light / `--primitive-neutral-07-a80` dark): The clickable cell's hover — one gentle step lighter in dark, one gentle step darker in light, always away from the pill fill.
- **Event bg** (`--color-calendar-event-bg` → `--color-bg-container-primary`): Resting fill of an event pill — the calendar container's own surface, so pills read as windows down to it.
- **Event bg hover** (`--color-calendar-event-bg-hover` → `--color-calendar-cell-bg`): A hovered pill takes the *resting* cell tint — always one step off the hovered cell it sits on (darker than that cell in dark, lighter in light). The "+N more" overflow row's hover shares it, so every interactive row inside a cell separates the same way.
- **Event border** (`--color-calendar-event-border` — `--primitive-neutral-04` light / `--primitive-neutral-06` dark): The hover-only hairline ring on the pill under the pointer — an obvious mid-grey that reads against both cell states, while resting pills carry a transparent border so the ring appears with no layout shift. Its own role because the container border colour sinks into the dark cell fill.

### Overlay & Controls
- **Scrim** (`--color-scrim` — rgba(0,0,0,0.5) light / rgba(0,0,0,0.7) dark): Modal backdrop behind Dialog, AlertDialog, Drawer and CommandPalette. Darker in dark mode so the modal still separates from the near-black floor.
- **Control thumb** (`--color-control-thumb` — chains to `--color-action-primary-text` in both themes): The circular thumb inside toggle switches (ToggleSwitch, SelectionCard's toggle indicator). Riding the on-action ink means every theme's AA-held action pairing keeps the thumb legible on its track — a light action fill (Smoke, Volt) gets a dark thumb automatically, where a fixed near-white value vanished into it.

### Text
- **Primary** (`--color-text-primary` — #050505 light / #F1F1F1 dark): Headlines and primary content.
- **Secondary** (`--color-text-secondary` — #303030 light / #BCBCBC dark): Emphasized body, card subheadings.
- **Tertiary** (`--color-text-tertiary` — #6D6D6D light / #A2A2A2 dark): Labels, helper text, captions.
- **Inverse** (`--color-text-inverse` — #A2A2A2 light / #303030 dark): De-emphasized secondary labels; inverts in dark mode.
- **On inverse** (`--color-text-on-inverse` — #F1F1F1 light / #0E0E0E dark): Text sitting on `--color-bg-container-inverse` surfaces (ImageCompare's handle label, the "today" markers).

### Icons
- **Primary** (`--color-icon-primary` — #6D6D6D light / #D6D6D6 dark): Default icon fill — tertiary buttons, input icons, nav icons.
- **Secondary** (`--color-icon-secondary` — #A2A2A2 light / #A2A2A2 dark): De-emphasized icons.

### Status (stable across themes — backgrounds invert, borders and text swap to their dark-mode pairs)
| Variant | bg (light) | border | text (light) | bg (dark) | text (dark) |
|---|---|---|---|---|---|
| `positive` | #ECFCF7 | `#06D6A0` | #024336 | #024336 | #ECFCF7 |
| `warning` | #FFF3EC | `#EF8247` | #552716 | #552716 | #FFF3EC |
| `error` | #FDEFF3 | `#EF476F` | #571727 | #571727 | #FDEFF3 |
| `info` | #EEF3FD | `#1E47B0` | #081633 | #081633 | #EEF3FD |
| `neutral` | #D6D6D6 | `#303030` | #232323 | #232323 | #F1F1F1 |

Each role also carries an **icon step** (`--color-status-<role>-icon`): the glyph colour for status indicators sitting on plain container surfaces (AgentPlan's step indicators, ToolCall's state glyph, SourceTrail's rows). The text step is tuned for its tinted status background and washes out on a bare container — in dark mode the near-white text tints all read as grey. Light uses the deep inks — green-08 #05A67C, orange-08 #C65E33, red-08 #C93A5C, blue-06 #345AC4, neutral-06 #6D6D6D — and dark brightens to mid-ramp hues that hold at least 3:1 on the dark container floor: green-07 #06D6A0, orange-07 #EF8247, red-06 #F16385, blue-05 #5475D4, neutral-04 #A2A2A2.

### Trend (theme-split — the "number went up / went down" role)
- Up: `--color-trend-up` — #024336 (green-10) light / #06D6A0 (green-07, the mint accent) dark
- Down: `--color-trend-down` — #571727 (red-10) light / #EF476F (red-07, the coral accent) dark

Stat deltas and any table mover colour through these, never through the accents directly: on light surfaces the vivid accents fall below readable contrast, so the light half uses the deep status inks, while the dark half keeps the accents, which read well on dark fills. The role exists so that "went up" has one colour everywhere it appears.

### Core Accents (data-viz, decorative — not for semantic status)
- Coral: `--color-core-accent-coral` #EF476F
- Amber: `--color-core-accent-amber` #EF8247
- Gold: `--color-core-accent-gold` #FFD166
- Mint: `--color-core-accent-mint` #06D6A0
- Cobalt: `--color-core-accent-cobalt` #1E47B0
- Violet: `--color-core-accent-violet` #9E47EF

### AI (reserved for AI entry points and surfaces)
The red → blue → teal gradient is the system's "a model answers here" signal, the AI counterpart to the action teal: ordinary actions keep the flat `--color-action-primary-bg`, AI affordances carry the gradient, and the gradient is never used for an ordinary action's job — or decoratively. Inside shared list chrome the reservation changes form rather than lapsing: an AI affordance in a uniform list never restyles its own row (a one-off row treatment would break the list before it signalled anything), but it may wear the gradient as a **passive mark** — a non-interactive ringed badge at the row's trailing edge, signalling the destination without touching the row's shape. The site palette's ask-the-chat row is the reference: its label stays the visitor's own query in the list's plain face, and the AI-ring chip rides the CommandPalette `trailing` slot (see that spec for the slot's contract). Always used as gradient stops (AiButton's ring and glow are the reference), never as flat fills for ordinary chrome.

| Token | Light | Dark |
|---|---|---|
| `--color-ai-gradient-start` | `red-05` #F37F9B | `red-05` #F37F9B |
| `--color-ai-gradient-mid` | `blue-05` #5475D4 | `blue-05` #5475D4 |
| `--color-ai-gradient-end` | `teal-07` #118AB2 | `teal-07` #118AB2 |
| `--color-ai-button-bg` | `neutral-00` #FFFFFF | `true-black` #000000 |

One luminous set serves both themes: the stops are bright enough to stay luminous on dark surfaces, while the deeper inks the light theme once used read as a heavy dark border on white. The teal stop sits deeper than the red and blue ones so the gradient ends on the same weight the action colour carries.

The family carries one solid fill alongside the stops: `--color-ai-button-bg` (`neutral-00` #FFFFFF light / `true-black` #000000 dark), the AI button's field. It is the theme's extreme surface — brighter than the light page floor, deeper than the dark one — so the AI entry point punches out of whatever it floats over rather than frosting into it. Reserved with the rest of the family: it fills AiButton's pill and the passive marks that wear its face (the site palette's ask chip), nothing else.

---

## Typography

### Font Family
The shipped theme resolves every role to **Nunito Sans**. No serif face, and no bundled monospace face — code contexts (CodeBlock, ToolCall names, Prose inline code) use the system mono stack codified as `--font-family-code`. The main font stack is `'Nunito Sans', sans-serif` via `--font-family-primary`.

Nunito Sans is a rounded humanist sans-serif. The rounded terminals give UI elements a friendly, approachable quality without sacrificing technical clarity. Inter is an acceptable substitute for prototyping; avoid Helvetica or Arial, which strip the rounded character.

**Family roles.** No type-scale step references `--font-family-primary` directly. The scale chains through two role aliases in `tokens-typography.css`, split along the tier boundary the Hierarchy table below draws:

| Role token | Resolves to | Feeds |
|---|---|---|
| `--font-family-heading` | `var(--font-family-primary)` | The display and heading tiers — Mega 1/2, Display 1/2, Sub Display, Heading 1–3 |
| `--font-family-body` | `var(--font-family-primary)` | The body tiers — Title Body, Paragraph Em, Paragraph, Paragraph SM (+ Em), Overline, Caption |

Both roles default to the primary family, which is what keeps the three levers coherent: override `--font-family-primary` and the whole system re-fonts as one face (this site's position); override `--font-family-heading` alone and headings take a display face while running text keeps the primary; override both roles and the primary token stops mattering. Title Body sits on the body role deliberately — it is a bold label inside working UI (card titles, table column heads), and a consumer pairing a serif display face with a sans text face wants that label in the text face, not the serif.

**Sourcing.** The typeface is deliberately not bundled with the npm package. The website self-hosts it via `next/font/google` (fetched from Google Fonts at build time and served first-party), Storybook loads it with a Google Fonts `<link>`, and package consumers load it however their stack prefers — then swap the whole system to another face by overriding `--font-family-primary`, or split faces through the family roles above. Material Symbols Rounded, by contrast, ships inside the package as a self-hosted woff2, so icons need no external setup.

### Type scale ladders

Every style bundle's `-size` (and pixel `-line-height`) resolves through two shared ladders in `tokens-typography.css`, named by the same rule as the spacing scale (rank = px / 4 x 100): `--font-size-300` (12px) up to `--font-size-3300` (132px), and `--font-line-height-400` (16px) up to `--font-line-height-1100` (44px). This is the type-size unlock: override a ladder step and every tier that reads it rescales together, without touching the tier bundles. The display tiers' unitless leadings (0.92-1.15) are ratios, not lengths, and stay in their bundles.

### Hierarchy

| Token prefix | Size | ≤768px | Weight | Line Height | Letter Spacing | Use |
|---|---|---|---|---|---|---|
| `--font-mega-1-*` | 132px | 64px | 300 | 0.92 | +2% | Marketing hero — never in app UI |
| `--font-mega-2-*` | 116px | 56px | 300 | 0.92 | +2% | Marketing hero — never in app UI |
| `--font-display-1-*` | 96px | 48px | 300 | 1.05 | +2% | Large marketing section heads |
| `--font-display-2-*` | 64px | 40px | 300 | 1.1 (1.15 ≤768px) | +1.5% | Marketing section heads |
| `--font-sub-display-*` | 30px | 24px | 300 | 44px (36px ≤768px) | +1.5% | Sub-section heads on marketing pages |
| `--font-heading-1-*` | 30px | — | 600 | 44px | +1.5% | Page-level h1 in app/docs |
| `--font-heading-2-*` | 26px | — | 600 | 32px | +1.5% | Section headings in app/docs |
| `--font-heading-3-*` | 22px | — | 600 | 28px | +1.5% | Sub-section headings in app/docs |
| `--font-title-body-*` | 16px | — | 600 | 24px | −1% | Card titles, table column heads, bold labels |
| `--font-paragraph-emphasis-*` | 16px | — | 500 | 24px | −1% | Emphasized body, button labels, input labels |
| `--font-paragraph-*` | 16px | — | 400 | 24px | 0 | Default running text |
| `--font-paragraph-sm-emphasis-*` | 14px | — | 500 | 20px | 0 | Compact button labels, badge text, form helpers (emphasized) |
| `--font-paragraph-sm-*` | 14px | — | 400 | 20px | 0 | Secondary body, helper text |
| `--font-overline-*` | 14px | — | 600 | 20px | +8% | Uppercase labels — eyebrows, map callout titles, code-language chips |
| `--font-caption-*` | 12px | — | 400 | 16px | 0 | Footnotes and disclaimers — the floor of the scale |

The ≤768px column is not a separate token set: the same `--font-*-size` tokens re-resolve inside a single `@media (max-width: 768px)` block at the bottom of `tokens-typography.css`. Anything set in display-tier tokens collapses automatically on mobile; the heading and body tiers never step (marked —). The Size and Weight columns, like everything token-owned, are the shipped theme's positions — the presets' `typeScale` lever and weight `extraOverrides` can move them per theme.

### Principles
- Mega and Display sizes (Mega 1/2, Display 1/2, Sub Display) are **marketing-only** — they belong on the documentation homepage and landing pages, never inside application UI or component pages. One sanctioned exception: the site chat's welcome ask line is set in `--font-sub-display-*`, because the empty widget is a landing moment, not working UI — the pair reads as small-semibold greeting over large-light invitation, the weight-contrast rule at welcome scale. The exception ends where the conversation starts: nothing in a live thread uses the display tier.
- Headings 1–3 use weight 600. Display styles use weight 300 in the shipped theme, and **never bold a display size within a theme** — the lightness is intentional and gives the shipped system its modern, airy character. Weight is a per-preset position like size: a preset may re-pitch the display tiers through its `extraOverrides` (Forest sets them to 600/500 to suit its heavier display face), but nothing inside a theme bolds a display token at the use site.
- **Display leading is unitless, and loosens as the size drops**: 0.92 on Mega, 1.05 on Display 1, 1.1 on Display 2 (1.15 on mobile, where the same headline wraps to more lines). Set solid, a display line reads as a poster on one line and closes up on two — and these headlines wrap, because a page title is a sentence. The heading and body tiers state leading in px instead, because at those sizes the line box is a rhythm the whole page snaps to.
- Positive letter-spacing (+1.5–2%) applies to all display and heading sizes. Negative letter-spacing (−1%) applies to title-body and paragraph-em — this tightening is what makes UI labels feel crisp.
- Buttons use `--font-paragraph-emphasis-*` (default) and `--font-paragraph-sm-emphasis-*` (compact). Always weight 500 on interactive controls.
- Overline is the uppercase label face, and always pairs with `text-transform: uppercase` at the use site — the tokens set the metrics, never the casing. Its +8% tracking is the one positive letter-spacing outside the display and heading tiers, because capitals at label size need air to stay legible. A component may keep the metrics and swap the family (MapCallout sets its title in the code face).
- Caption (12px) is the floor, and it is for text the reader can find but never has to read to use the screen: the site chat's disclaimer line, a footnote under a chart. Never on a control, never for helper text (that is Paragraph SM), never for running copy.

---

## Page Layout & Heading Hierarchy

This section documents how to apply the typographic scale when building new pages in the documentation website. Following these rules ensures every page reads with the same visual hierarchy.

### Heading Roles

| Level | Token | Size | Weight | Role |
|---|---|---|---|---|
| Page title (`h1`) | `--font-display-2-*` | 64px | 300 | Route title — rendered once per page at the top, outside the markdown body |
| Major section (`h2`) | `--font-sub-display-*` | 30px | 300 | Top-level content sections (e.g. Colors, Typography) |
| Sub-section (`h3`) | `--font-heading-3-*` | 22px | 600 | Named groupings within a section (e.g. Token Tiers, Action / Brand) |
| Minor heading (`h4`) | `--font-title-body-*` | 16px | 600 | Inline labels or additional sub-groups |

### The Weight-Contrast Rule

**Weight contrast is the primary differentiator between adjacent heading levels — not size alone.**

- `h2` is 30px / weight 300 (light). `h3` is 22px / weight 600 (bold). The 8px size gap alone is not enough — the jump from 300 → 600 is what makes the hierarchy unmistakable at a glance.
- Never use the same weight for two consecutive heading levels. A heading that is only slightly smaller than the one above it, at the same weight, will look like a duplicate rather than a sub-item.
- The correct pairing is always: **light heading above, bold heading below** at the major → minor transition.

### Section Dividers

`h2` elements carry a `border-bottom: var(--border-025) solid var(--color-divider)` by default to visually close the preceding section. Do not insert `<hr>` elements between sections — they duplicate the divider. The border on `h2` is the only section separator needed.

**The header-divider rhythm is fixed**: heading, then `--padding-500` (20px) of clearance, then the rule, then `--gap-1000` (40px) before the section's content. The rule belongs to the heading, so the tight side is above it and the generous side is below — inverted spacing (a rule floating far under its heading, or content hugging the rule) reads as the rule labelling the content instead. SectionTitle owns the heading-to-rule half; the page's section layout owns the 40px below, usually as the section container's gap. A page whose sections run a tighter internal gap must make up the difference on the divider itself (margin-bottom on the title — /overview and /docs/get-started are the precedent) rather than letting the rule sit closer to the content than everywhere else. The hand-rolled equivalents (the case-study and journal `resumeSectionHeader`-style headers) follow the same geometry. The one deliberate exception is markdown article bodies (the /blueprints renderers — see Applying to Markdown Pages below): their h2s head running article copy, not page sections, so they run a tighter internal rhythm (8px above the rule, 16px below) that would read as page-section spacing nowhere else.

### Applying to Markdown Pages

When a page renders markdown (via `react-markdown` or similar), apply these heading styles using `:global()` selectors scoped to the markdown body container:

```css
/* h2 — major section, light and airy */
.markdownBody :global(h2) {
  font-size: var(--font-sub-display-size);      /* 30px */
  font-weight: var(--font-sub-display-weight);  /* 300 */
  padding-bottom: var(--primitive-padding-200);  /* 8px above the rule */
  border-bottom: var(--border-025) solid var(--color-divider);
  margin-top: var(--gap-1500);                   /* 60px above, 40px on mobile — semantic, so the section rhythm collapses with the token */
  margin-bottom: var(--primitive-gap-400);       /* 16px below */
}

/* h3 — sub-section, bold contrast */
.markdownBody :global(h3) {
  font-size: var(--font-heading-3-size);        /* 22px */
  font-weight: var(--font-heading-3-weight);    /* 600 */
  margin-top: var(--primitive-gap-1000);          /* 40px above */
  margin-bottom: var(--primitive-gap-200);       /* 8px below */
}

/* h4 — minor heading, same weight as h3, smaller size */
.markdownBody :global(h4) {
  font-size: var(--font-title-body-size);       /* 16px */
  font-weight: var(--font-title-body-weight);   /* 600 */
  margin-top: var(--primitive-gap-400);
  margin-bottom: var(--primitive-gap-100);
}
```

### Responsive Collapse

The display tier collapses **in the token layer**, once, at 768px (see the Hierarchy table): page titles (`--font-display-2-*`) go 64px → 40px and section heads (`--font-sub-display-*`) go 30px → 24px with no per-page CSS. Pages that reference the tokens are responsive by default — never re-hardcode a mobile font size in a page module. One sanctioned exception, marked with a `ds-allow(typography)` directive at the site: the chat panel's welcome ask line repeats the sub-display step as a *container* query on the panel's own width (`SiteChat.module.css`), because the docked panel is phone-width on desktop windows the viewport media query calls large — the panel wears the phone scale below a 768px container, and only the fullscreen takeover on a wide window keeps the 30px size. It restates the same 24px/36px pair, and the two must move together.

- Headings 1–3 and the body tiers do **not** step; 22–30px headings stay readable on phones.
- The weight contrast rule still applies; do not change weights at any breakpoint.
- The one sanctioned exception: a mega-1 hero (home page, 404) may keep a hand-tuned two-stage ramp (132 → 64 → 40) in its own module, commented as a deliberate override.

---

## Spacing & Layout

### Base Units
- **Gap (flex/grid spacing):** 4px-based scale via `--gap-*` tokens.
- **Padding (internal element padding):** 2px-based micro scale via `--padding-*` tokens.
- **Borders:** 1px hairline (`--border-025`) and 2px emphasis (`--border-050`).

### Gap Scale
| Token | Value | Typical Use |
|---|---|---|
| `--gap-050` | 2px | Tightest inline spacing (icon + label micro-gap) |
| `--gap-100` | 4px | Compact component internals |
| `--gap-200` | 8px | Standard icon-to-label gap, button icon spacing |
| `--gap-300` | 12px | Form field stack spacing |
| `--gap-400` | 16px | Card internal element spacing |
| `--gap-500` | 20px | Section element spacing |
| `--gap-1000` | 40px | Card-to-card spacing, major internal gaps |
| `--gap-1500` | 60px (40px ≤768px) | Between component groups |
| `--gap-2000` | 80px (60px ≤768px) | Between major page sections |
| `--gap-3000` | 120px (80px ≤768px) | Page-level section breaks |

The three section-rhythm steps (`xxl`–`xxxxl`) compress one notch down the primitive scale below 768px, in a `@media` block at the bottom of `tokens-light.css` — the same single token-layer breakpoint typography uses. Steps `xl` and below never change.

### Padding Scale
| Token | Value | Typical Use |
|---|---|---|
| `--padding-050` | 2px | Badge vertical padding |
| `--padding-100` | 4px | Micro insets |
| `--padding-150` | 6px | Compact button/input vertical |
| `--padding-200` | 8px | Standard button vertical, badge horizontal |
| `--padding-300` | 12px | Compact button horizontal, compact input horizontal |
| `--padding-400` | 16px | Standard input horizontal, card inner padding baseline |
| `--padding-500` | 20px | Standard button horizontal |
| `--padding-1000` | 40px | Large card insets |
| `--padding-1500` | 60px (40px ≤768px) | Section-level insets |

### Layout Philosophy
Whitespace communicates hierarchy. Dense elements use micro-gaps (2–8px); comfortable reading areas use md/lg gaps (16–20px); major layout regions use xl–xxxxl (40–120px). The system relies on spacing contrast rather than dividers — overuse of `--color-divider` is a design smell.

---

## Composition

Tokens constrain values; this section constrains choices. Element-level rules (colors, radii, type) make components consistent — these rules make **pages** consistent. Every rule here is stated so that a page assembled from the components lands right on the first pass, without per-page taste decisions.

### The rhythm ladder

Page-level vertical space is a statement of relatedness: the gap between two things says how related they are. The ladder is exhaustive — these five roles are the *only* page-level gaps. A spacing value that is not on the ladder is component-internal spacing, and belongs to a component.

| Role | Token | Value | Meaning |
|---|---|---|---|
| Section break | `--gap-2000` | 80px (60px ≤768px) | Separates page regions. The only gap between sections. |
| Heading bind | `--gap-1000` | 40px, flat | Ties a heading to the content it introduces. |
| Intro bind | `--gap-500` | 20px | Ties a tagline or lede to its body copy. |
| Group bind | `--gap-200` | 8px | Ties a label to the items it names. |
| Column gutters | `--layout-*` | presets | Gutters and rail widths are facts of the layout, set once at the shell — never per-page choices. |

Because the ladder's meanings differ by a full visual step (80 / 40 / 20 / 8), a reader can recover the page structure from spacing alone. Never use an off-ladder gap to "fine-tune" a page-level relationship: if 40px feels wrong between a heading and its content, the content is wrong, not the gap.

### The dashboard rung set

Rule 7 below says tiling is a template's decision; a dashboard is that template. When a page is an app shell laying Panels in a grid, the ladder gains one inner rung set, worked out on the labs marketing rebuild and stated here so every dashboard lands on the same density:

| Role | Token | Value | Meaning |
|---|---|---|---|
| Card gutter | `--gap-500` | 20px | Between Panels, in every direction. |
| Card inset | `--padding-500` | 20px | A Panel's own padding — the gutter and the inset match, so the grid reads as one fabric. |
| Region gap | `--gap-400` | 16px | Between a Panel's regions: header to chart, chart to legend row. |
| Cluster gap | `--gap-200` / `--gap-300` | 8 / 12px | Within a cluster: icon to text, tiles in a legend row, buttons in a toolbar. |

The recurring mistake this prevents: reaching for the 2px and 4px tokens (`--gap-050`, `--gap-100`) for cluster spacing — both spacing audits on the labs rebuild found the same error, and at cluster scale those values read as touching, not spaced.

### Composition rules

1. **Parent owns spacing.** Siblings are spaced by their container's `gap`, never by margins on the children. A child that carries its own outer margin breaks in every context except the one it was tuned for.
2. **One level of chrome.** Components that carry their own container (charts, tables, calendars) sit bare under a heading — never wrapped in a card. A card wrapper is only for content with no chrome of its own. Two levels of container on one element is always a bug — and so is the over-correction: bare means without a wrapper, never with the component's own borders and background stripped to fake flatness. The component keeps exactly the chrome it ships with.
3. **Dividers are the last resort.** Separation comes from whitespace (the ladder) and containers first; a divider is only for where spacing contrast cannot do the job — long uniform lists, table rows. A divider under a heading that whitespace already separates is decoration, and decoration is not a divider's job. Next to content that draws its own lines (bordered tables, calendars), a section heading always separates by whitespace alone — on a line-heavy page, every added line is noise.
4. **Constrain the column, not the content.** A page has one content max-width; anything wider than its column (tables, diagrams, code) scrolls inside its own container. Never cap the width of a paragraph — cap the column it sits in.
5. **A header is one region.** A page title and its tagline bind at the heading tier (40px), never separated by a section break. The first section break on a page sits *below* the complete header group.
6. **Page rhythm stops at a component's edge.** The ladder governs the space *between* components and headings; a component's internal spacing is its own spec's business. Composing a page never involves reaching into a component to adjust its insides.
7. **Sections stack.** A page is one column of full-width sections; side-by-side placement is for the items *inside* a band (a metrics row of Stats), not for sections themselves. Tiling sections into a grid is a template's decision to make, never a page-by-page one — tested both ways on a dense dashboard, the stack reads calmer and the grid reads cramped.

### Template screens

A template rebuilds a whole product screen from the system alone, and the family has conventions beyond the page rules above — worked out across the marketing dashboard, sales pipeline, roadmap planner, and relay console rounds of review, and stated here so the next screen lands right without re-learning them:

1. **A reference product is direction, never a blueprint.** A template may start from another product's screen, but it is composed from this system's idioms, and the standard for every choice is the rendered siblings, not the reference. Furniture the reference has and the family lacks — a status badge beside the title, an avatar cluster in the header, drawn imitation browser chrome, a bespoke rollup strip — does not come along: a piece with no sibling precedent is left out by default, not invented.
2. **One job per control species, one size per row.** A data view's filter row is matching compact `Dropdown` selects (160px, the marketing dashboard's convention) beside the component's own search — never a mix of segmented controls, filter chips, and inputs doing the same job in one row. A *stage toolbar* — the strip floating over an immersive stage, the relay console's precedent — may pair one view-flip `SegmentedControl` with the filter cluster, at the default size with wider selects, because it is read at arm's length over a map rather than at table density; the filters themselves stay one species. What survives every toolbar: controls sharing a row share one size, and no job is done by two species in the same row.
3. **The stage earns the viewport.** The screen's point — the table, the board, the chart — takes the room. A KPI band is a choice, not template furniture: both the sales pipeline and the roadmap planner shipped without one, because the stage deserved the screen. When metric tiles do appear, they follow the dashboard rung set above.
4. **People are initials.** Demo humans render through the Avatar's initials fallback — never generated portrait imagery or photographs. The fiction stays legible as fiction, and the rows stay quiet.
5. **The content column caps at 1680px — unless the stage is the content.** The family's shell centres a 1680px column inside the viewport-tall shell. A screen whose stage *is* the content — a map, a projection instrument — may run the full viewport width instead (the relay console's call); it is a stage decision, made once per screen, never a per-section one.

---

## Shapes

### Border Radius Scale
**Concentric containers.** A container wrapping a radiused element derives its own radius as the inner radius plus the inset between them, never an independent value — parallel curves are what make the nesting read deliberate, and a theme that squares the pill token squares both at once. The Composer's shell is the founding case (its spec section owns the derivation); SegmentedControl's track (`calc(var(--radius-pill) + var(--padding-100))`) and the site's BrandSwitcher trigger follow it. At the shipped 999px pill the sum saturates and nothing changes.

| Token | Value | Use |
|---|---|---|
| `--radius-050` | 2px | Reserved — rare micro elements |
| `--radius-100` | 4px | Badges (`ds-badge`) |
| `--radius-200` | 8px | Small sub-elements, inner nested surfaces |
| `--radius-300` | 12px | Inputs, cards (standard), modals |
| `--radius-400` | 16px | Large feature cards, hero containers |
| `--radius-600` | 24px | Card/EntityCard navigation tiles, Panel, chat bubbles and chat card furniture (MessageCard, ToolCall, AgentPlan, InterruptCard), AppSidebar's floating variant, CommandPalette's panel, oversized hero containers, page-level sections |
| `--radius-1200` | 48px | Pill containers, oversized decorative elements |
| `--radius-pill` | 999px | All buttons, every variant, toggle thumbs |

**Key rule:** Buttons are always `--radius-pill` (pill shape). Inputs are always `--radius-300` (12px), with one sanctioned departure: the Composer shell holds its own component-local `--ds-composer-radius` (29px) for concentric geometry with its send button (see the Composer spec). Card and EntityCard — the navigational tiles — use the larger `--radius-600` (24px) to read as destinations rather than form surfaces, and the chat's card furniture (MessageCard, ToolCall, AgentPlan, InterruptCard) shares that 24px shell with the bubbles around it, so a conversation's surfaces read as one family. This contrast — rounded pill CTAs vs softer-cornered inputs vs generously rounded tiles — is intentional and consistent.

### Icon Size Scale

| Token | Value | Use |
|---|---|---|
| `--icon-size-500` | 20px | Compact controls, inline affordances, chips, list chevrons |
| `--icon-size-600` | 24px | Default — the size an icon is unless told otherwise |
| `--icon-size-800` | 32px | Feature icons — EntityCard, section headers |
| `--icon-size-1200` | 48px | Marketing and empty-state illustration icons |

**The icon tile pattern** — an icon on a filled circle, for KPI cards and table cells: a `--radius-pill` disc in `--color-bg-container-tertiary` holding a `--icon-size-500` glyph in `--color-text-primary`, at 40px (stat and KPI headers) or 32px (table cells and list rows). It is a page pattern, not a component: four declarations in module CSS, worked out on the labs marketing rebuild.

Components set **`--icon-size`**, never `font-size`:

```css
.ds-thing__icon { --icon-size: var(--icon-size-500); }
```

**Node-accepting icon props** — every `icon`-shaped prop in the library (item-level entries like a tab's or a menu row's included) is typed `string | React.ReactNode`: a string renders as a Material Symbol glyph, and any other node renders as-is inside the same wrapper span, minus the `material-symbols-rounded` class. That contract is the bring-your-own-icons paved path — the repo imports no icon set, and a consumer passes Lucide (or any other) elements directly; a custom SVG should draw with `currentColor` so it inherits text colour the way the bundled glyphs do. Props with a string default (TreeView's `folder`/`draft`, Toast's variant glyphs, ThreadPanel's `edit_square`) keep resolving those defaults when the prop is omitted. New components must ship icon props on this contract, never string-only.

**Icon style hooks** — the bundled woff2 is the full Material Symbols variable font, and `src/fonts/material-symbols.css` exposes every Google axis as a consumed custom property, settable at any scope: `--material-symbols-fill` (0 line / 1 filled), `--material-symbols-weight` (100–700 stroke), `--material-symbols-grade` (−50–200 contrast). `opsz` is deliberately absent from `font-variation-settings` so `font-optical-sizing: auto` feeds the rendered size into the axis (an explicit value would pin it). Two shipped applications of the hooks: `[data-theme="dark"]` sets grade −25 so light-on-dark glyphs do not bloom, and `.icon-filled` is the one-icon shorthand for the fill axis. These are consumer-override hooks with fallbacks, not registry tokens — the fallback values in that file are their defaults.

`.material-symbols-rounded` reads that one property for `font-size`, `width`, and `height` together, so the glyph and its layout box can never disagree. It *consumes* the variable and never declares it — which is what keeps a component rule from colliding with the icon font's own styles. Setting `font-size` directly on an icon is a bug: it changes the glyph without changing the box.

**The scale starts at 20px, and that floor is not arbitrary.** Material Symbols is a variable font whose `opsz` (optical size) axis ranges 20–48. Optical sizing thickens strokes as an icon gets smaller; below 20 the axis clamps, so a 14px icon would be drawn with 20px-tuned strokes and scaled down, reading thin and fragile. Every step therefore sits inside the axis range, and `font-optical-sizing: auto` lets the browser track the step automatically — `opsz` is deliberately omitted from `font-variation-settings`, because an explicit axis value there would override the automatic behaviour.

**Documented exceptions.** Glyphs that live *inside* a control's geometry rather than acting as icons stay off the scale: the check inside ToggleSwitch's 20×20 thumb and SelectionCard's toggle (14px, 12px compact), and the miniature component mock-ups in the website's component index cards. Each is commented in place.

**Dark mode** applies `GRAD: -25` to counter the optical bloom of light glyphs on dark surfaces — it thins strokes without changing glyph width, which is what `wght` would do.

---

## Elevation & Depth

The system uses **color-block first, shadow rare** philosophy. Depth is communicated through the container background hierarchy, not box shadows.

| Level | Treatment | Use |
|---|---|---|
| Page floor | `--color-bg-page-primary` — no border, no shadow | Body background, app root |
| Container primary | `--color-bg-container-primary` — no border | Sidebars, card fills, section bands |
| Container secondary | `--color-bg-container-secondary` — optional `--color-bg-container-border` border | Nested cards, inner panels |
| Container tertiary | `--color-bg-container-tertiary` — no shadow | Pressed/active states, deepest UI surfaces |
| Input | `--color-input-bg-primary` with `--color-input-border-primary` hairline | Text inputs, textareas, dropdowns |
| Status containers | Colored bg + colored border per variant | Alerts, toasts, badges |

Standard containers never carry shadows. The only shadows in the system are two semantic elevation tokens, defined per theme (stronger opacity in dark mode so they read against the #050505 floor):

- **`--shadow-floating`** (`0 4px 16px rgba(0,0,0,0.12)` light / `0.55` dark) — anchored floating surfaces: menus, popovers, pickers, floating panels, chart tooltips, toasts. Any component that floats over the page without a scrim takes this token; grep `--shadow-floating` for the current set rather than trusting a list here.
- **`--shadow-modal`** (`0 8px 32px rgba(0,0,0,0.2)` light / `0.6` dark) — modal surfaces: Dialog, AlertDialog, and Drawer panels and Lightbox's media, paired with the `--color-scrim` backdrop.

Never write a literal *elevation* shadow in component CSS — use one of these two tokens or no shadow at all. Hairline inset rings and focus halos built with `box-shadow` are not elevation and are permitted: Swatch and ColorPicker use inset rings to keep pale swatches legible, and Slider's thumb carries a `box-shadow` focus halo. (One documented elevation exception: the interactive Card hover lift — see Do's and Don'ts.)

---

## Motion

Motion is quiet and functional — it confirms an interaction, reveals structure, or signals loading. Interface motion never decorates. The one sanctioned exception is the **ambient background**, which is scenery rather than interface: it belongs to no control, communicates no state, and sits behind everything at `z-index: -1` (see the Ambient background section below). The whole vocabulary is defined in `tokens-motion.css` (theme-agnostic, single `:root` like typography): nine `--motion-duration-*` tokens and five `--motion-ease-*` curves. Compose a duration with an easing instead of writing literal values — never hardcode `0.2s ease` in component CSS.

**Durations — core scale** (day-to-day UI):

| Token | Value | Use |
|---|---|---|
| `--motion-duration-fast` | 150ms | Quick feedback: hovers, icons, tooltips, nav links |
| `--motion-duration-base` | 200ms | The default — color/opacity/border transitions |
| `--motion-duration-slow` | 300ms | Structural change: accordion, sidebar width, toast enter |
| `--motion-duration-slower` | 600ms | Deliberate page-entrance reveals |

**Durations — extended** (special-purpose; named so they stop being magic numbers, but reach for the core four first): `--motion-duration-instant` (75ms, below the threshold where a change reads as motion — it takes the edge off the Composer growing a line, nothing more), `--motion-duration-deliberate` (400ms, carousel slide), `--motion-duration-loop-spin` (1000ms, spinner rotation), `--motion-duration-loop-shimmer` (1800ms, skeleton shimmer and the agent label sweep), `--motion-duration-loop-matrix` (1400ms, the twelve-slot cycle every AgentStatus dot pattern shares).

**Easings:**

| Token | Value | Use |
|---|---|---|
| `--motion-ease-standard` | `ease` | General default for color/opacity transitions |
| `--motion-ease-emphasized` | `cubic-bezier(0.4, 0, 0.2, 1)` | Size/layout changes (sidebar width, accordion height) |
| `--motion-ease-entrance` | `cubic-bezier(0.16, 1, 0.3, 1)` | Expressive decelerate for enter animations (modals, dropdowns, toasts) |
| `--motion-ease-linear` | `linear` | Continuous motion (spinner, progress bars) |
| `--motion-ease-spring` | `cubic-bezier(0.34, 1.56, 0.64, 1)` | Playful overshoot (toggle switch thumb) |

**Reduced motion contract:** `tokens-motion.css` collapses every duration token to 0.01ms under `prefers-reduced-motion: reduce`, and a universal guard flattens remaining hardcoded transitions/animations. Components that consume the tokens respect the preference automatically — never write component-level `prefers-reduced-motion` queries.

**Migration status:** all component and website CSS composes the tokens — a literal duration or easing survives only where a `ds-allow(motion)` directive sanctions it in place (Skeleton's shimmer curve, the ambient AI glows, the decorative background floats; grep `ds-allow(motion)` for the current set — a count here would only rot). JS-driven timings (hover show/hide delays and the hover exit grace, toast auto-dismiss and the exit-animation unmount sync, carousel autoplay, transient feedback resets, scroll settle, the streaming reveal's rate floor and drain window, the summary panel's think beat and reveal budget…) have their own single home: the shared constants in `src/tokens/motion.ts`, published as `rift-ds/tokens/motion` — the module is the authoritative list. Most are schedule timings rather than animation durations, which the reduced-motion guard deliberately does not touch (the summary panel's think beat is one: it still stages under the preference, because it schedules a swap rather than pacing motion); components use them as defaults, overridable per instance through props. A component that animates from a JavaScript timer or frame loop checks the preference itself, since the CSS guard cannot see JS — or rides a library default that does: the recharts-backed charts animate their marks in under recharts' own `isAnimationActive: 'auto'` guard, which the chart-set spec owns (a literal `true` would override it). A grep for `prefers-reduced-motion` in `src/components` is therefore the inventory of *self-checking* components only (Carousel never starts autoplay under it, and StreamingText skips its reveal so each chunk appears whole; they are examples, not the set) — the delegated recharts set never contains the string.

### Ambient background

The website's background is **ShaderField** — the published component, not a bespoke site surface. Its rendering contract is specified in the Components chapter; what follows is only why an animated ambient surface is allowed at all, and how the site composes it: a document-top absolute band (540px standard, 900px under the home page's extended marker, the full viewport only under the full-bleed one) that scrolls away with the page opening rather than following the viewport.

Three properties make this an exception that does not erode the rule:

- **It is scenery, not interface.** Nothing about it is a signal, so its motion cannot be mistaken for feedback.
- **It degrades to the CSS blobs.** The site keeps eight blurred CSS discs painted underneath on every page — ported 1:1 to the shader's coordinate space, so the field and its fallback are the same picture, give or take the framing `crop` gives the field on a narrow viewport — and drives their visibility from the status ShaderField reports. No WebGL2, a lost GPU context, a renderer that stalls before its first frame, or the config's kill switch all land on the same quiet fallback. The blobs are hidden while the renderer is still resolving, rather than painted and then swapped. The deliberate cost is that a machine which cannot run the shader shows bare page floor for that first moment before the blobs fade in.
- **It honours reduced motion in JavaScript.** This is the part worth knowing, and it is the component's job rather than the site's: the guard in `tokens-motion.css` is CSS-only and cannot see a `requestAnimationFrame` loop. The rule against component-level `prefers-reduced-motion` queries still holds for everything that consumes the duration tokens; a JS animation loop is outside that contract by construction.

Each blob carries a 14–22s drift period inherited from the CSS blobs it replaced, scaled by the config's `speed` parameter — so the cycles you actually see are those periods divided by `speed`, not the raw numbers. Every tuneable value — the eight field parameters and the eight blob definitions — lives in `website/src/data/shader-background.json`, validated against the token registry so the field can never sample a token that does not exist.

---

## Components

**Overlay behaviors are shared, not per-component.** The modal overlays (Dialog, AlertDialog, Drawer, CommandPalette, Lightbox) delegate their invisible machinery to the internal hooks in `src/behaviors/` — one implementation of each behavior, so an edge-case fix lands once. The contract: Escape routes through a layer stack and dismisses only the topmost open overlay (a non-dismissible top layer swallows it — nothing beneath can close through a blocking modal); focus is trapped over the *visible* focusable elements, and restored on close or unmount only when the previously focused element still exists in the document; everything outside the open overlay is made `inert`, so a screen reader's virtual cursor cannot browse the page behind a modal; and the page scroll lock has two strategies — `pin` (default: the body is fixed in place at its scroll offset, because iOS Safari ignores `overflow: hidden` on the body — a touch, or a field focusing under the soft keyboard, still scrolls a hidden-overflow page out from under an overlay; counted, so stacked modals release it only when the last one closes) and `cancel-events` (CommandPalette's: the scrollbar stays rendered and scroll input is cancelled outside the panel's own list). The pin's counter is the layer's one published subpath (`rift-ds/behaviors/useScrollLock` — everything else in `src/behaviors/` stays internal): host chrome with overlays of its own joins the same counter through `acquireScrollLock`/`releaseScrollLock`, because two body locks that cannot see each other unlock the page under whichever overlay closes last — a site nav drawer closing beneath an open Dialog is the canonical case, and the website's `lib/scroll-lock.ts` is that consumer. The stack owns the document-level *dismissal* listeners for these five — CommandPalette keeps one listener of its own for the global Cmd+K hotkey, which must hear the keyboard while the palette is closed, and Lightbox one for its gallery arrow keys, gated on open (its spec owns it) — and the non-modal overlays still gate an outside-click listener on their open state until they migrate onto the stack.

### Button

**`ds-button`** — The primary interactive element. Always pill-shaped (`--radius-pill`), and the label never wraps (`white-space: nowrap`, shared with AiButton) — a squeezed layout shortens around the button rather than folding the pill into two lines. Text uses `--font-paragraph-emphasis-*` (default) or `--font-paragraph-sm-emphasis-*` (compact). Icon size: 24px (default), 20px (compact). Transitions: background-color, border-color, and opacity at `--motion-duration-base` / `--motion-ease-standard`. A trailing icon (`iconRight`) nudges 2px right on hover at `--motion-duration-fast` / `--motion-ease-entrance` — a small confirmation that the button leads somewhere; never on disabled buttons. Disabled state: `opacity: 0.4`, `cursor: not-allowed` — never hidden.

| Variant | Fill | Border | Text |
|---|---|---|---|
| `primary` | `--color-action-primary-bg` (#0E6E8F light / #3CA5C6 dark) | none | `--color-action-primary-text` (#CFEAF3 light / #052F3E dark) |
| `secondary` | transparent | `--color-action-primary-border` (#052F3E light / #3CA5C6 dark) 1px | `--color-text-primary` |
| `tertiary` | `--color-action-passive-bg` (near-transparent) | none | `--color-action-passive-text` |
| `neutral` | `--color-action-neutral-bg` (#D6D6D6 light / #232323 dark) | none | `--color-action-neutral-text` |
| `destructive` | transparent | `--color-core-accent-coral` (#EF476F) 1px | `--color-core-accent-coral` |

Hover: primary and secondary → `--color-action-primary-bg-hover`; neutral → `--color-action-neutral-bg-hover`; destructive → fills with its coral border colour. Active: primary and secondary → `--color-action-primary-bg-active`; neutral → `--color-action-neutral-bg-active`. Hover and active walk away from the page — deepening in light mode, brightening in dark — so the pressed fill never sinks toward the floor behind it. `neutral` is the filled-but-quiet option: a solid grey pill for actions that deserve a button's full silhouette without the action teal's weight — unlike `tertiary`, its background is always shown at rest.

Sizes: `default` (padding 8px × 20px), `compact` (padding 6px × 12px).

**A tertiary button never stands alone in an action row.** Its transparent resting field has no visible edge, so a lone tertiary reads as misaligned text rather than as an action. An action row leads with a primary or secondary button, and tertiary follows it — nav lists and icon rows, where every entry is deliberately passive, are the exception, not action rows.

Loading state: `loading` puts a `variant="inherit"` Spinner in the left icon slot (24px default / 16px compact), keeps the variant's full-colour appearance (no disabled dim), blocks interaction (`pointer-events: none`, `cursor: progress`, `disabled` attribute), and sets `aria-busy`. A loading `href` button renders the `<button>` branch so the link is truly inert.

### ButtonGroup

**`ds-button-group`** — Inline container that composes Buttons for nav and subnav contexts. Two orientations: `horizontal` (default — `--gap-500` between buttons, used in top navigation) and `vertical` (`--gap-050`, left-aligned hug-content column for subnav/mobile). Purely compositional: each entry is a full `ButtonProps` config passed straight through to Button, so variants and disabled states mix freely — except that an entry with no `variant` defaults to `tertiary`, not Button's own `primary`, so nav groups read as passive by default. `role="group"` with an optional `ariaLabel`.

### CircularButton

**`ds-circular-button`** — Round icon-only button, 40px (default) or 32px (compact), always `--radius-pill`. The same variants as Button minus `destructive` — `primary` (`--color-action-primary-bg` fill), `secondary` (outlined, `--border-025` + action border tokens), `tertiary` (ghost, `--color-action-passive-bg-hover` on hover), `neutral` (solid `--color-action-neutral-bg` fill, always shown at rest) — with the same state set (`default`/`hover`/`active`/`disabled`). Icon is a single Material Symbol at 24px default / 20px compact. Renders as `<a>` when `href` is set. `ariaLabel` is required — there is no visible label. Follows the published API contract like Button: `disabled` is the real boolean (`state` stays as the documentation-only affordance), the ref forwards to whichever element renders, and unrecognised props — `data-*`, `aria-*`, native event handlers — spread onto it. `loading` swaps the icon for a `variant="inherit"` Spinner, keeps full-colour appearance, blocks interaction, and sets `aria-busy` — same contract as Button. **Self-labelling tooltip**: because there is no visible label, the button wears a Tooltip carrying `ariaLabel` by default, on hover and focus — the same convention MessageActions established for its icon row. `tooltip` accepts a string for different wording or `false` to opt out (a host that labels the control another way, or owns the button's box directly — SplitButton's trigger); `tooltipPosition` picks the side, for hosts whose edge would clip the default top placement (the chat panel's header opens its tooltips downward).

### SegmentedControl

**`ds-segmented-control`** — Horizontal set of mutually exclusive options on a `--color-bg-container-primary` track with a `--radius-pill` pill silhouette. The active segment takes `--color-action-primary-bg` with `--color-action-primary-text`, drawn by a single sliding pill: one absolutely positioned indicator element slides and resizes between segments (`transform` + `width` at `--motion-duration-base` / `--motion-ease-entrance`), its box written from the active segment's measurements on every change and re-measured without animation on resize. A move that lands before the pill's first painted frame snaps rather than slides, so a post-hydration correction (the header theme toggle hydrates on the server's segment, then jumps to the visitor's) never animates across the control on page load. Until the first client-side measurement lands (SSR, first paint) the active segment paints its own background, so the selection is never missing; once the pill takes over, hovering the active segment darkens the pill itself (`--color-action-primary-bg-hover`). Idle segments use `--font-paragraph-emphasis-*` in `--color-text-secondary` with `--color-action-passive-bg-hover` on hover, and an idle segment's icon shares the label's `--color-text-secondary` — never a dimmer icon tier — so icon-only segments keep label-strength contrast. `variant="neutral"` swaps the active fill to `--color-action-neutral-bg`/`--color-action-neutral-text`, for sets where the selection should not carry the teal selection convention's weight. Segments accept an optional Material Symbol icon and per-segment `disabled`. The track carries a `--border-025` `--color-bg-container-border` hairline. Sizes: `default`, `compact`; `fullWidth` stretches segments across the container. `collapse` makes the strip shed parts rather than overflow when its container is too narrow for it: the control measures its own three natural widths once from the full render — everything, labels alone, icons alone — and picks the widest that fits on every later resize, so the ladder never oscillates and a container that grows back re-reads the same numbers. **Decoration goes before meaning**: the icon is shed first and the label last, it only falls to icons when every segment has one, and a hidden label is clipped rather than removed, so the segment keeps its accessible name. Off by default — the control overflows as it always did. AgentRail is the first consumer, where it is what makes four tabs fit a 360px rail. Announces as a tablist (`role="tablist"`, segments are `role="tab"` with `aria-selected`). Keyboard: Arrow keys cycle enabled segments, Home/End jump to the ends.

### ToggleGroup

**`ds-toggle-group`** — A row of two-state buttons sharing a bordered `--radius-200` container (`--border-025` on `--color-bg-container-border`). Single-select by default; `multiple` allows any combination. Active items fill with `--color-action-primary-bg` and `--color-action-primary-text` (`variant="neutral"` swaps them to the `--color-action-neutral-*` greys); items can be text labels or Material Symbol icons (`icon: true`). Sizes: `default`, `compact`. Group-level `disabled` applies the standard `opacity: 0.4` treatment. `role="group"` + `aria-pressed` per item.

### SplitButton

**`ds-split-button`** — A primary action with an attached menu of alternatives ("Save" beside "Save as draft"), composed from Button, CircularButton and DropdownMenu inside one pill silhouette. The outer corners stay `--radius-pill` and the meeting edges square to `--radius-050`; the main segment's inner-edge padding tightens one step (`--padding-300` default, `--padding-200` compact), since the squared edge has no pill cap to clear; and the chevron is optically centred, nudged 2px (1px compact) toward the flat edge, because the flat-plus-round-cap shape carries its visual mass left of the geometric centre. The split is drawn per variant: on the filled variants (`primary`, `neutral`) a `--border-025`-wide gap of bare page between the filled segments, on `secondary` the gap collapses and the trigger drops its leading border, so the main segment's own outline is the single divider line rather than two hairlines side by side. Variants: `primary`, `secondary`, `neutral` — both segments always match. Sizes: `default`, `compact`. The label segment fires `onClick`; the chevron segment is a CircularButton (accessible name from `menuLabel`) opening a DropdownMenu with the standard entry tree, keyboard behaviour, and `align` options. `loading` runs Button's loading contract on the label segment and holds the trigger inert at full colour with a progress cursor — the loading contract's no-dim appearance, because a disabled dim on one half would split the pill into two unrelated controls; `disabled` takes both segments down together, and when both flags are set the disabled dim wins across the whole control, matching Button's own precedence.

### Badge

**`ds-badge`** — Inline status label. Radius `--radius-100` (4px) — notably tighter than buttons and inputs. Text: `--font-paragraph-sm-emphasis-*` (14px/500). Padding: 2px vertical × 8px horizontal. Each of the five status variants (`info`, `positive`, `warning`, `error`, `neutral`) maps directly to its `--color-status-*-bg` and `--color-status-*-text` tokens. Badges are borderless by decision (2026-08-26): the tinted fill carries the status on its own, and the old per-variant borders sat below 3:1 against their fills — a transparent `--border-025` border remains for stable geometry. Renders with `role="status"` for accessibility.

### Chip

**`ds-chip`** — Compact pill for attributes, filters, and inline metadata. Always pill-shaped (`--radius-pill`) like Button. Neutral by default: `--color-bg-page-primary` fill, `--color-bg-container-border` hairline, `--color-text-secondary` text in `--font-paragraph-sm-emphasis-*` (14px/500). Icon size: 24px (large), 20px (default), 16px (compact) — below `large`, smaller than Button's because the chip is a tighter control.

Three sizes, matching the Button ladder one rung down: `compact` (24px tall), `default` (32px), and `large` (40px). `large` is the exception to "tighter than a Button" — it takes the default Button's `--padding-200`/`--padding-500` box, `--font-paragraph-emphasis-*` (16px/500) text, and 24px icons, so a pill that is a primary tap target rather than metadata reads at the same weight as the body text around it. PromptSuggestions is its intended consumer.

Distinct from Badge: a Badge communicates *status* through the five status colours and is never interactive; a Chip is neutral and optionally interactive.

Renders as a `<span>` when non-clickable, a `<button>` when given `onClick` (with `aria-pressed` when `selected` is set). `onRemove` adds a trailing close button (Material Symbol `close`) with its own accessible label; when a chip is both clickable and removable the container stays a `<span>` and the main region becomes an inner button so buttons never nest.

States:
- **Hover** (clickable): `--color-action-passive-bg-hover` fill; **Active**: `--color-action-passive-bg-active`
- **Selected**: `--color-action-primary-bg` fill with `--color-action-primary-text` — same teal selection convention as SegmentedControl
- **Disabled**: `opacity: 0.4`, `cursor: not-allowed`
- **Focus**: standard 2px `--color-action-primary-bg` focus ring

Sizes: `default` (padding 6px × 12px — 32px tall), `compact` (padding 2px × 8px — 24px tall).

### Alert

**`ds-alert`** — Container for inline status messages. Same five variants as Badge. Wider form — carries an icon, title, and description block. References same `--color-status-*` tokens. Use when message needs to persist in the layout (vs Toast which is ephemeral).

### Banner

**`ds-banner`** — Full-width page-level status strip: the third sibling in the feedback family. Alert sits inline in the content, Toast interrupts and leaves, Banner spans the page and stays until dismissed. Same five status variants on the `--color-status-*-bg`/`-text` pairs, square-cornered and edge-to-edge by design — it belongs to the page frame, not the content column. One `--font-paragraph-sm-*` line with an emphasised leading `title` in the `-em` weight, a per-variant Material Symbol (neutral takes `campaign`, the announcement horn, rather than Alert's `info`), an `action` slot for a compact Button, and an optional dismiss control; `align="center"` centres the content for short environment notices. Stateless like Alert: `dismissible` shows the close control, `onDismiss` reports the click, and removal is the consumer's render decision. `role="status"`.

### Input / Textarea

**`ds-input`** — Label + field + helper text stack with 8px (`--gap-200`) vertical gap. Field: `--radius-300` (12px), `--color-input-bg-primary` fill, `--color-input-border-primary` (#D6D6D6 light / #232323 dark) hairline. Label: `--font-paragraph-emphasis-*`. Body: `--font-paragraph-*`. Helper: `--font-paragraph-sm-*` in `--color-text-tertiary`.

States:
- **Default**: `--color-input-border-primary`
- **Hover**: `--color-input-border-hover` (#2C9AB9 light / #118AB2 dark — mid teal, clearing 3:1 against each page)
- **Focus**: `--color-input-border-selected` (#0E6E8F light / #2C9AB9 dark — the active border, on the action colour's weight)
- **Error**: `--color-status-error-border` (#EF476F) on border and helper text
- **Disabled**: `--color-input-bg-disabled` fill, `--color-input-border-disabled`, `--color-input-text-disabled` on label/text

Icon slots (Material Symbols Rounded): left icon at 16px from edge, right icon at 16px from edge. Compact size: padding 6px × 12px, icon 20px.

Field-level error state is where the system's responsibility ends, by design: form-level validation — inline error summaries, field grouping, when to validate — is orchestration, and it belongs to the consumer's form layer, not to a component library. Every form control carries the same error vocabulary — an `error` flag that recolours the helper line and marks the control invalid, plus a status border wherever the control has one — which is exactly the surface a form library needs to drive.

### Checkbox

**`ds-checkbox`** — Custom checkbox with an inline SVG check (and minus for `indeterminate`) instead of font glyphs, for pixel-perfect centring. The box is `--radius-200` with a `--border-050` border; checked state fills `--color-action-primary-bg` with `--color-action-primary-text-active` stroke, hover shifts to `--color-action-primary-bg-hover`. Sizes: `default`, `compact`. Disabled: `opacity: 0.4`, `cursor: not-allowed`. `helperText` renders a `--font-paragraph-sm-*` line under the label (stacked in a text column beside the box, the box aligned to the first line); `error` recolours it to `--color-status-error-border` and sets `aria-invalid`, both announced via `aria-describedby`. A `CheckboxGroup` wrapper renders a labelled list of checkboxes bound to a value array — it composes `<Field group>`, so the group label, required marker, helper/error line and the `aria-labelledby`/`aria-describedby` wiring are Field's, with group-level `error` carrying `aria-invalid` on the `role="group"` node.

### RadioButton

**`ds-radio`** — Single-select control: a `--radius-pill` circle with a `--border-050` ring and an inner dot that fills `--color-action-primary-bg` when checked; hover shifts the ring toward `--color-action-primary-border-tertiary`. Implemented as a `role="radio"` element with `aria-checked`, Space/Enter activation, and managed `tabIndex`; grouping is React state through `RadioGroup` (`value`/`onValueChange` — the `name` prop is a deprecated no-op, since nothing renders a native `<input type="radio">` for it to group). Label uses `--font-paragraph-*`; `helperText` adds a `--font-paragraph-sm-*` line under it, announced via `aria-describedby` — `error` recolours it, deliberately without `aria-invalid`, which ARIA does not allow on `role="radio"`; the group form carries it instead. `RadioGroup` composes `<Field group>` (label, required marker, helper/error, `aria-labelledby`/`aria-describedby`/`aria-invalid` on the `role="radiogroup"` node). Disabled: `opacity: 0.4`.

### ToggleSwitch

**`ds-toggle-switch`** — Binary on/off switch: a 42×24px `--radius-pill` track filled with `--color-action-primary-bg` when on, holding a 20px `--color-control-thumb` circle that slides with a spring-curve transition and shows a check icon. Rendered as a `<button role="switch" aria-checked>`; the optional inline label uses `--font-paragraph-*`, and `helperText` stacks a `--font-paragraph-sm-*` line under it (announced via `aria-describedby`; `error` recolours it and sets `aria-invalid`). Sizes: `default`, `compact`. Used for binary settings — an on/off preference with immediate effect. (The site's own theme control outgrew it: three states made it a SegmentedControl.) SelectionCard's `toggle` mode reuses this exact anatomy.

### Slider

**`ds-slider`** — Range input styled with a two-tone track: `--color-action-primary-bg` fill up to the current value, `--color-bg-container-tertiary` beyond it, with a `--radius-pill` thumb that gains a `--color-action-primary-bg-hover` focus ring. Native `<input type="range">` underneath, so keyboard and screen-reader behaviour come for free (`min`/`max`/`step` props). Composes inside Field: `label` (a real `<label htmlFor>` — the range input is labelable), `helperText`, `error`, `required`, and `showValue`, which prints the live value in Field's `aside` slot opposite the helper; with any of them present the root stacks as a column (`--fielded`), and a bare slider keeps its original row untouched. Unlabelled sliders fall back to an `aria-label` (native attribute first, then the deprecated `ariaLabel` prop, then "Slider"). Sizes: `default`, `compact`. Disabled: `opacity: 0.4`.

### Swatch

**`ds-swatch`** — Clickable colour tile: a 24px `<button>` (20px `compact`) whose background is the `value` colour, passed through the `--ds-swatch-color` custom property so the stylesheet stays data-free. Shapes: `circle` (default, `--radius-pill`) and `square` (`--radius-200`). A hairline `--color-bg-container-border` inset keeps light colours visible on light surfaces; `selected` draws the theme-aware ring (`--color-bg-page-primary` gap + `--color-text-primary` outline) and sets `aria-pressed`. Purely presentational — no `'use client'`, so it renders from Server Components. Disabled: `opacity: 0.4`.

### ColorPicker

**`ds-colorpicker`** — Colour picker composed in `Field`: a bordered trigger (`--radius-300`, input border tokens) holding a checkerboard-backed swatch and optional hex text (`showText`), opening a `--shadow-floating` panel with a saturation/brightness area (2D `role="slider"`: pointer, arrow keys, and Home/End on the saturation axis), a hue slider, an optional alpha slider (`showAlpha`, emits 8-digit hex below 100%), and a hex field. Works controlled (`value` + `onValueChange`, fires live while dragging) or uncontrolled (`defaultValue`); `name` renders a hidden input for native form submission. The white/black overlay gradients and hue spectrum are colour-space constants written as `hsl()` literals — the one sanctioned departure from hex-free component CSS, since no theme token can represent them. Sizes: `default`, `compact`. Disabled: `opacity: 0.4`.

### DateInput

**`ds-date-input`** — Form field wrapping the native date input with the standard input anatomy: label (`--font-paragraph-emphasis-*`), `--radius-300` field on `--color-input-bg-primary` with `--color-input-border-primary`, hover/selected borders (`--color-input-border-hover` / `--color-input-border-selected`), calendar icon in `--color-icon-primary`, and helper/error text below (`--font-paragraph-sm-*`, error via `--color-status-error-border`). Supports `min`/`max`, `required`, `disabled` (input disabled tokens), and sizes `default`/`compact`. Value format is `YYYY-MM-DD`.

### DatePicker

**`ds-date-picker`** — Inline calendar: month header with `--radius-pill` chevron nav buttons, `Su–Sa` day-header row, and a grid of day cells. The selected day fills `--color-action-primary-bg` with `--color-action-primary-text` at `--radius-pill`; today is highlighted; out-of-range days (`min`/`max`) and the `disabled` state get the standard muted treatment. Container: `--radius-300` on `--color-input-bg-primary` with `--color-input-border-primary`. Sizes: `default`, `compact`. Emits `onDateSelect` with `YYYY-MM-DD`.

### TimePicker

**`ds-timepicker`** — Time-of-day form field, the clock sibling of Dropdown. The trigger is a real `<button>` styled with the input anatomy (label via Field, `--radius-300`, input border/bg tokens, helper/error text), led by a `schedule` icon at `--icon-size-500` and closed by a rotating chevron. Open state reveals a `--radius-300` listbox on `--color-bg-page-primary` elevated with `--shadow-floating`, max-height 240px with scroll, which opens scrolled to the selected or first option. Options are generated from `minTime`/`maxTime`/`stepMinutes` (defaults 00:00, 23:30, 30) and rendered with `tabular-nums`; `hourFormat` switches the display between "2:30 PM" and "14:30" while the value stays a 24-hour `HH:MM` string (controlled `value` or uncontrolled `defaultValue`, `onValueChange`). The selected option is marked with `--color-action-primary-text-tertiary` and a check in `--color-action-primary-bg`; hover and keyboard focus use `--color-action-passive-bg-hover`. Listbox keyboard pattern (arrows, Home/End, Enter, Escape) with `aria-expanded`, `aria-activedescendant` and `aria-haspopup="listbox"` wiring. Sizes: `default`, `compact`.

### EventCalendar

**`ds-event-calendar`** — Month-at-a-glance schedule: a full month grid with event pills on their days, an overflow row once a day is full, and prev/next month navigation. DatePicker selects a date; this one shows a schedule. The container is `--color-bg-container-primary` at `--radius-300` with a hairline border and `--padding-400`; the header pairs the month name (`--font-title-body-*`) with two `--radius-pill` passive nav buttons and an optional `actions` slot (a "New event" Button). Day cells are `--color-calendar-cell-bg` at `--radius-200` in a 7-column grid with `grid-auto-rows: 1fr`, so each week row shares the height of its fullest day instead of fixing a cell height; outside-month cells fade to the transparent container fill. Today's day number takes the inverse chip (`--color-bg-page-inverse` / `--color-text-on-inverse`) — the filled version of DatePicker's today ring, because a ring disappears at calendar density. The `selectedDate` day takes the teal chip instead (`--color-action-primary-bg` / `--color-action-primary-text`, DatePicker's selected convention — the selected item of a mutually exclusive set), winning over the today chip when both land on one day.

Event pills are `--color-calendar-event-bg` chips at `--radius-100`: an accent dot, a truncating `--font-paragraph-sm-emphasis-*` title (`.ds-event-calendar__event-title` — a host squeezing the calendar into a miniature column may hide it, reducing pills to their dots; the titles survive as the pills' accessible names), and a `tabular-nums` time that hides under 768px so titles keep at least a few characters. The dot's colour routes through `--ds-event-accent` and resolves to the `--color-core-accent-*` roles (`coral`/`violet`/`cobalt`/`amber`/`gold`/`mint`; default neutral via `--color-status-neutral-border`) — never the action teal, which stays reserved for actions. Within a day, untimed events sort first, then by time text. Days past `maxEventsPerDay` (default 3) collapse into a "+N more" row. Pills, day numbers, and the overflow row render as buttons only when their callback (`onEventClick`, `onDateClick`) is provided — otherwise they are inert spans, so a read-only calendar contains no fake affordances. With `onDateClick` set, the whole cell is a pointer target too (`cursor: pointer`, hover to `--color-calendar-cell-bg-hover`), while the day-number button stays the accessible control; clicks landing on a button inside the cell defer to that button. Cell and pill fills come from the calendar-surfaces roles: the cell keeps its hover while the pointer is on a pill inside it, and the pill separates through its own hover (`--color-calendar-event-bg-hover`, the resting cell tint — one step off the hovered cell, darker than it in dark and lighter in light) plus a hover-only `--color-calendar-event-border` ring; resting pills carry a transparent border, so the ring costs no layout shift. The "+N more" overflow row hovers onto the same `--color-calendar-event-bg-hover` step. Months are `YYYY-MM` strings on the controlled/uncontrolled pair convention (`month`/`defaultMonth`/`onMonthChange`); like DatePicker, the grid is deliberately not `role="grid"` — a labelled group of buttons is honest where a claimed 2D keyboard model would be a lie.

### Dropdown

**`ds-dropdown`** — Select-style form field. Closed state matches Input anatomy (label, `--radius-300`, input border/bg tokens, helper/error text); open state reveals a `--radius-300` listbox on `--color-bg-page-primary` elevated with `--shadow-floating`, max-height 240px with scroll. Options support disabled entries, grouped sections (`groups`) with headings and separators, an optional per-option `icon` — a Material Symbol at `--icon-size-500` in `--color-icon-primary`, drawn before the label in the option row and on the closed trigger while selected (inline in the value so its single-line ellipsis holds) — and an optional per-option `font` — a CSS stack the option's label renders in (routed through the `--ds-dropdown-font` custom property, runtime data rather than theme), so a typeface picker previews each face in the row and in the closed trigger. The selected option is marked with `--color-action-primary-text-tertiary`; hover uses `--color-action-passive-bg-hover`. Listbox keyboard pattern (arrows, Home/End, Escape, Enter) with `aria-expanded`/`aria-activedescendant` wiring. Sizes: `default`, `compact`. Width is deliberately `100%` — a form field fills its field column; when a Dropdown is toolbar furniture instead (a date-range select in a panel header, a table filter), the consumer caps it with a fixed width via `className`, which is the sanctioned sizing hook. For action menus (not form values) use DropdownMenu.

### Combobox

**`ds-combobox`** — Filterable select: a text field that narrows a listbox as the user types. Control matches Input anatomy (label, `--radius-300`, input border/bg tokens, helper/error text) with a leading 24px `search` icon and a trailing chevron that rotates when open; the menu is a `--radius-300` surface on `--color-bg-page-primary` elevated with `--shadow-floating`, max-height 280px with scroll. Options carry an optional `description` second line (`--font-paragraph-sm-*` tertiary) and support disabled entries plus grouped sections. `multiple` renders selections as `--radius-pill` chips in `--color-bg-container-primary` inside the control, each with a remove button; Backspace on an empty query pops the last chip. `clearable` adds a clear button, `loading` swaps the list for a status row, and `emptyMessage` covers the no-match case. Async callers pair `onSearchChange` with `manualFiltering` to filter upstream. Full combobox keyboard pattern (arrows, Enter, Escape, Tab) with `aria-expanded`/`aria-activedescendant` wiring. Sizes: `default`, `compact`. Use Dropdown when the option list is short and static; Combobox is for long, searchable, or server-backed lists.

### RichDropdown

**`ds-rich-dropdown`** — Dropdown's rich sibling: a select where every option is a self-portrait. The shared cell pairs a 24px swatch dot of the option's key colour (Swatch's geometry — hairline `--color-bg-container-border` inset so light colours stay visible; `--radius-pill` by default, with a per-option `swatchRadius` override so a sharp-cornered look squares its own dot) with the option name set in its own heading face and a detail line set in its own body face. The per-option fonts, colour and swatch radius are runtime data, not theme, so they reach the stylesheet through custom properties (`--ds-rich-dropdown-heading-font`, `--ds-rich-dropdown-body-font`, `--ds-rich-dropdown-color`, `--ds-rich-dropdown-swatch-radius`), falling back to `--font-family-heading` / `--font-family-body` when an option carries no faces. The name takes `--font-title-body-*`, the detail `--font-paragraph-sm-*` in `--color-text-tertiary`. The closed trigger renders the selected option with the same cell, so the current choice previews itself too. Everything else is Dropdown's anatomy: Field composition, `--radius-300` trigger on the input tokens, a `--shadow-floating` listbox on `--color-bg-page-primary` (max-height 320px — the cells run taller than Dropdown's plain rows), selected name in `--color-action-primary-text-tertiary` with a check in `--color-action-primary-bg`, hover/focus rows on `--color-action-passive-bg-hover`, and the full listbox keyboard pattern with `aria-expanded`/`aria-activedescendant` wiring. Controlled (`value` + `onValueChange`) or uncontrolled (`defaultValue`). The dividing line with Dropdown: when the label alone is enough, use Dropdown — its per-option `font` already covers a typeface previewing itself; RichDropdown is for choices whose whole look is the point — theme presets, brand kits, style packages.

### FileInput

**`ds-file-input`** — Click-or-drop upload zone plus a list of chosen files. The dropzone is a dashed `--border-025` `--color-input-border-primary` rectangle at `--radius-300` with a centred 24px `upload_file` icon and instruction copy; hovering moves the border to `--color-input-border-hover`, and an active drag fills `--color-bg-container-primary` with the selected border. The real `<input type="file">` stays in the DOM (visually hidden) for form semantics, with the zone exposed as `role="button"` and Enter/Space activation. The file list is fully controlled — each row is a `--radius-300` hairline container with a `description` icon, name, human-readable size, an optional 4px `--color-action-primary-bg` progress track, and a 32px ghost remove button. Per-file `error` swaps the row to the error tokens with an `error` icon and message in `--color-status-error-text`. Sizes: `default`, `compact`.

### NumberInput

**`ds-numberinput`** — Numeric field composed in `Field`, with the native spinners hidden and replaced by decrement/increment stepper buttons (Material Symbols `remove` / `add` at `--icon-size-500`) flanking a centred `<input type="number">`. The shell carries the input anatomy: `--radius-300` (12px), `--color-input-bg-primary` fill, `--color-input-border-primary` hairline, hover `--color-input-border-hover`, `:focus-within` `--color-input-border-selected`. Steppers clamp to `min`/`max` and disable at the bounds; a typed out-of-range value clamps into range on blur; stepping from an empty field starts at 0 and clamps into range. Works controlled (`value`, `number | ''`) or uncontrolled (`defaultValue`); `onValueChange` receives `number | null` (null when empty) and fires on typing alongside the native `onChange`, and alone on stepper clicks, where no native change event exists. Stepper hover uses `--color-action-passive-bg-hover`. Sizes: `default`, `compact`. Error: `--color-status-error-border` on the shell. Disabled: the input disabled token set on the shell, `cursor: not-allowed`, steppers at `opacity: 0.4`.

### TagInput

**`ds-taginput`** — Multi-value text input holding committed entries as removable tags. The shell (`ds-taginput__shell`) is a focus-within box matching Input anatomy: `--radius-300`, `--color-input-bg-primary` fill, `--color-input-border-primary` hairline, hover `--color-input-border-hover`, focus-within `--color-input-border-selected`, error `--color-status-error-border` on border and helper text. Tags are `--radius-pill` pills in `--color-bg-container-secondary` with `--font-paragraph-sm-emphasis-*` labels and a trailing remove button (Material Symbols `close` at `--icon-size-500`, accessible name "Remove {tag}"). Enter or comma commits the trimmed draft as a tag (no duplicates, no empty entries); Backspace on an empty draft removes the last tag; `maxTags` stops new entries at the limit. Controlled via `values` plus `onValuesChange`, uncontrolled via `defaultValues`; the native `onChange` fires for draft edits. Composes Field for the label, helper and error wiring. Sizes: `default`, `compact` (draft text drops to `--font-paragraph-sm-*`). Disabled: `opacity: 0.4`, `cursor: not-allowed`, tags not removable. Use Combobox when the values come from a known option list; TagInput is for free-text entries.

### PinInput

**`ds-pininput`** — Segmented one-time-code input composed in `Field`: a row of one-character cells (`--gap-200` between them) below the standard label, with helper/error text underneath. Each cell is a 44×52px `<input maxLength={1}>` at `--radius-300` on `--color-input-bg-primary` with a `--color-input-border-primary` hairline, centred `--font-paragraph-emphasis-*` text, and the input hover/focus borders (`--color-input-border-hover` / `--color-input-border-selected`); keyboard focus adds the 2px `--color-action-primary-bg` ring. Typing fills a cell and advances focus, Backspace clears and retreats, arrows move, and paste distributes a full code from cell 0. `format` is `numeric` (default, rejects non-digits, `inputMode="numeric"`) or `alphanumeric`; `mask` renders password cells; `length` defaults to 6. `onValueChange` emits the joined code, `onComplete` fires once when every cell fills; the first cell carries `autoComplete="one-time-code"` for OTP autofill. The cells wrap in a `role="group"` labelled by Field's label, and every cell has an accessible name ("Digit 1 of 6"). Error: `--color-status-error-border` on cell borders. Disabled: cells at `opacity: 0.4`, `cursor: not-allowed`, input disabled tokens.

### FilterBar

**`ds-filter-bar`** — A wrapping row of filter chips at `--gap-200` for narrowing a collection. Each chip borrows Chip's pill language (`--radius-pill`, `--color-bg-page-primary` on a `--border-025` `--color-bg-container-border` hairline, `--font-paragraph-sm-emphasis-*` in `--color-text-secondary`); a filter with anything active flips to the teal selected fill (`--color-action-primary-bg` / `--color-action-primary-text`, matching Chip's selected convention) and grows a trailing clear button. The chip summarises its state: bare label, "Label: Option" for one choice, "Label: n" for more. The popover is a real listbox — `--color-bg-container-primary` at `--radius-300` under `--shadow-floating`, options at `--font-paragraph-sm-*` with a teal `check` glyph reserving its column — driven by `aria-activedescendant` so focus never leaves the chip: arrows move, Enter/Space toggle, Escape closes, single-select filters (`multiple: false`) close on choice. State is one `Record<filterId, string[]>`, controlled (`values` + `onValuesChange`) or uncontrolled (`defaultValues`); a "Clear all" text button appears once anything is active. `compact` tightens to Chip's compact paddings.

### Rating

**`ds-rating`** — Star-scale rating control. A radio group of Material Symbol steps (`icon` defaults to `star`, `max` to 5): filled steps take `--color-core-accent-gold` with the icon font's `FILL` axis at 1, empty steps sit in `--color-icon-secondary`. Hover previews the would-be selection (steps scale to 1.1 at `--motion-duration-fast`); selection is controlled (`value`) or uncontrolled (`defaultValue`) with `onValueChange`, and `allowClear` lets re-selecting the current step clear back to 0. Keyboard follows the radio-group pattern: the checked step holds the roving tab stop, arrows move and select, Home/End jump to the ends. `readOnly` renders the same row as `role="img"` with a spoken "N out of M" label; `disabled` dims to the standard 0.4. Sizes: `default` (`--icon-size-600` steps), `compact` (`--icon-size-500`).

### Toast

**`ds-toast`** — Ephemeral notification via `ToastProvider` + `useToast()` hook. Rendered via React portal into `document.body`. Default duration: 5000ms. Pauses on hover/focus. Progress bar animates across bottom. Position: configurable (`top-right`, `bottom-right`, etc.), defaults to `bottom-right`. Max stack: 5 toasts. Five variants share the same `--color-status-*` token mapping as Badge/Alert. The container sits at `z-index: 200`, the library's top layer, flush to its corner; both are consumer hooks. A host that keeps its own fixed chrome in that corner sets `--ds-toast-inset-top/bottom/left/right` so the queue stacks clear of it, and raises `--ds-toast-z` above its own overlays: toasts are transient feedback and must be seen, so they outrank persistent chrome. The website does exactly this for its chat launcher (`globals.css`), and moves the queue left of the panel while it is docked.

Default icons (Material Symbols Rounded): `info`, `check_circle`, `warning`, `error`, `info` for each variant. Override via `icon` prop. Dismissal plays the exit animation (`--motion-duration-base`), the unmount following on `MOTION_EXIT_SYNC_MS`.

### NotificationCenter

**`ds-notification-center`** — The persistent inbox for everything that happened while the user was away: Toast interrupts, this accumulates. A `--color-bg-container-primary` panel at `--radius-300` with a hairline border, so it works dropped from a bell icon or inline on a page. Header: `--font-title-body-*` title with a tertiary "N unread" line beside it (a polite live region), and a mark-all-read text control on the right in `--color-action-primary-text-tertiary` (an action, so it may take the action colour) that only renders when `onMarkAllRead` is provided. Optional filter tabs sit under the header — `--radius-pill` pills with `--font-paragraph-sm-emphasis-*` labels and `tabular-nums` counts, active pill on `--color-bg-container-tertiary`, `role="tablist"` with arrow-key movement, each tab wired to the list below via `aria-controls` — and the list, a `role="tabpanel"` labelled by the active tab, scrolls beneath a `--color-divider` rule. Filtering is the consumer's job: swap the children when the tab changes. No children renders a built-in compact EmptyState (overridable via `emptyState`).

**`ds-notification-item`** — One row of the inbox, exported alongside: a leading `media` slot (Material Symbol name or an element such as an Avatar, `--icon-size-600`) rendered as a fixed column the width of its largest medium — an sm Avatar, the `--icon-size-800` rung — with smaller media centred in it, so every row's text shares one left edge whether its medium is an avatar or a bare icon; a title row with right-aligned time (`--font-paragraph-sm-*` tertiary), supporting copy in `--color-text-secondary`, and an `actions` row for compact Buttons. `unread` emphasises the title to `--font-paragraph-sm-emphasis-*` and adds an 8px dot filled with `--color-status-info-border` at full border weight, Meter's small-mark reasoning — the weight change alone is too quiet to scan for, so the dot is the signal and has to register at a glance — informational, so it takes the info status colour rather than the action teal — with visually hidden "Unread" text for screen readers. Rows separate with `--color-divider` hairlines; renders a semantic `<article>`.

### ProgressBar

**`ds-progress-bar`** — Determinate progress as a horizontal `--radius-pill` track in `--color-bg-container-tertiary` with a `--color-core-accent-mint` fill scaled to the clamped 0–100 value. Optional percentage label (`showLabel`) in `--font-paragraph-sm-emphasis-*`. `role="progressbar"` with `aria-valuenow`/`-valuemin`/`-valuemax` and an `ariaLabel` describing what is loading. Sizes: `default`, `compact` (bar height).

### Meter

**`ds-meter`** — Level indicator for a known quantity: storage used, tokens spent, password strength, battery left. The counterpart to ProgressBar — same `--radius-pill` track in `--color-bg-container-tertiary`, but the number means "how full", not "how far along", and the fill is coloured by the five status roles so the level carries its own judgement. The fill uses the full-strength `--color-status-*-border` weight, not the tinted `-bg` — a tint would vanish in an 8px ribbon. Optional header row: `label` in `--font-paragraph-sm-emphasis-*` on the left, a `showValue` readout in tabular figures on the right (`valueText` replaces the default percentage and is spoken via `aria-valuetext` — "128k of 200k tokens"). Custom `min`/`max` ranges; the fill animates between levels at `--motion-duration-base` and grows in from empty on mount (`--motion-duration-slower` `--motion-ease-emphasized`, collapsed by the reduced-motion token guard). `role="meter"` with the value attributes, named by `label` or `aria-label`. Sizes: `default` (8px track), `compact` (4px). Presentational — no `'use client'`, renders from a Server Component.

### Skeleton

**`ds-skeleton`** — Loading placeholder with a pulsing fill stepping between `--color-bg-container-primary` and `--color-bg-container-secondary`. Three shapes: `text` (`--radius-200` line, multi-line via `lines` — the last line renders at 75% width), `circular` (`--radius-pill`), and `rectangular`. `width`/`height` accept any CSS length. Announced as `role="status"` with `aria-label="Loading"` and `aria-busy`.

### Spinner

**`ds-spinner`** — Indeterminate loading indicator: an SVG circle with a `--color-bg-container-tertiary` track and a rotating arc in `--color-action-primary-bg` (`variant="primary"`) or `--color-text-secondary` (`variant="neutral"`); `variant="inherit"` draws both circles in `currentColor` (track at 0.25 opacity) so the spinner matches the surrounding control — it's what Button and CircularButton use for their `loading` state. Sizes `sm`/`md`/`lg` (16/24/36px). `role="status"` with a configurable `label`. Use Spinner for indeterminate waits and ProgressBar when the completion fraction is known.

### StatusDot

**`ds-status-dot`** — The bare status mark: a `--radius-pill` dot in one of the five status roles beside an optional `--font-paragraph-sm-*` label in `--color-text-secondary`. The dot colours through the plain-surface `--color-status-*-icon` steps (the deep inks in light, the mid-ramp hues in dark), never the text tints — it sits on bare containers, where the tinted-text steps wash out. Sizes ride the icon scale at half-diameter (`sm`/`md`/`lg` = half of `--icon-size-500/md/lg` — 10/12/16px), so the dot can never drift from the glyphs it sits beside. `pulse` radiates a ring of the dot's own colour (a `::after` inheriting the fill, swelling to 2.2× and fading on `--motion-duration-loop-shimmer`) for live states — recording, online now, deploy in flight; under reduced motion the ring parks invisible rather than strobing. Where Badge is a text label on a tinted fill, StatusDot is the mark alone — table rows, avatars, nav items. Root is `role="status"`; the dot itself is `aria-hidden`, so a bare dot needs its meaning carried by a row label or a passed-through `aria-label`. Purely presentational — no `'use client'`.

### EmptyState

**`ds-empty-state`** — Placeholder for a list, table, search, or dashboard with nothing to show. Centred stack: a 48px `--radius-pill` `--color-bg-container-primary` disc holding a 24px Material Symbol, a `--font-title-body-*` headline in `--color-text-primary`, supporting copy in `--font-paragraph-sm-*` tertiary capped at 420px, and an action slot that wraps consumer-provided Buttons. `variant="bordered"` adds a dashed `--color-bg-container-border` container at `--radius-300` — use it inside a card, table, or panel; `plain` sits directly on the page. `size="compact"` drops to a 36px icon disc, `--font-paragraph-emphasis-*` headline, and `--padding-500`, for dense sidebars. The `icon` prop takes a Material Symbol name or an arbitrary element for illustrations. Write the description as the next action, not just a statement of absence.

### Card

**`ds-card`** — Table-of-contents navigation tile. Two parts: `.ds-card__preview` (content/component preview area) + `.ds-card__title` (h3 label below). Used in component/foundations index grids. When `interactive`, gains `role="button"` and keyboard support. Background: `--color-bg-container-primary-semi`; radius: `--radius-600` (24px). Interactive and case-study variants lift on hover with `0 8px 24px rgba(0,0,0,0.3)` — the system's one sanctioned container shadow (see Do's and Don'ts). The case-study variant's cover slot takes either an image (`coverSrc`) or drawn content (`cover` — an SVG, a chart, a live preview); both fill the same fixed-ratio box, so a grid keeps one cover shape whichever kind each card was given. The cover is inset, not full-bleed: `--padding-300` (12px) of card surface surrounds a `--radius-300` (12px) cover, keeping the inner corners concentric with the card's 24px shell — the same geometry rule the Composer follows.

### EntityCard

**`ds-entity-card`** — Compact icon-or-image + label card. Used specifically on Icons and Logos pages in the Foundations section. Takes a Material Symbol name (`icon`) or image path (`imageSrc`). Centered layout with label beneath. Radius: `--radius-600` (24px), matching Card. Not interactive — display-only.

### SelectionCard

**`ds-selection-card`** — Card-based form control for selecting from a list. Three modes: `radio` (single select), `checkbox` (multi-select), `toggle` (per-card on/off). Each card renders the option label, optional description, and an indicator (radio dot / checkbox / toggle switch). Full keyboard navigation and ARIA roles. Selected state: teal border and background. Use when options need more space or explanation than a standard radio/checkbox group.

**When to use which card:**
- `Card` → navigational tile in a documentation index grid
- `EntityCard` → icon or logo display in a foundations catalog
- `SelectionCard` → form input when options need card-style layout

### CardStack

**`ds-card-stack`** — A deck of cards showing one at a time, the rest peeking out beneath it; the stack is the pattern, not the cards — any children work, Card and EntityCard are the intended fillings, and every card in a deck should share one size. Rendering is a CSS grid with every item in the same cell; each item's place in the deck is a single transform driven by two custom properties — `--ds-card-stack-peek` (the per-card offset, default `--gap-400`) times the item's `--ds-card-stack-pos`, with `scale(1 - 0.04 × pos)` from a bottom-centre origin so buried cards narrow while their bottom edges still peek below. The container pads its own bottom by `peek × --ds-card-stack-peek` so the deck occupies its true height. Flipping forward plays a lift-and-settle: the top card animates up and away (`translateY(-18%)`, a 3° tilt, fading — percentages of the card itself, so the choreography scales with whatever the deck holds) over `--motion-duration-deliberate` `--motion-ease-emphasized`, and the index commits on `animationend`, never a timer, so reduced motion (which collapses the duration via the token guard) commits immediately. Flipping back commits first and drops the incoming card in from above on `--motion-ease-entrance`. Position moves between slots transition on the same duration. Flip by clicking the top card (clicks landing on a link or control inside the card keep their meaning — `advanceOnClick` turns the rest off), or with the arrow keys when the stack has focus. `index`/`defaultIndex`/`onIndexChange` follow the controlled/uncontrolled pair convention; `loop` (default on) wraps the deck. The root is a focusable `role="group"` with `aria-roledescription="card stack"`; non-top cards are `inert` and `aria-hidden` so a buried card's controls can never take focus, and a visually hidden `role="status"` announces "Card X of N" as the deck turns.

### Panel

**`ds-panel`** — The plain dashboard surface: `--color-bg-container-primary` fill at `--radius-600`, `--padding-500` inside, a `--gap-400` column, no border and no shadow. Card cannot play this role — it requires a title and carries its own navigational look — so Panel is what a dashboard's regions build on, with charts dropped in `bare` and LegendTile rows beneath them. `padding="compact"` steps to `--padding-400`, `"none"` removes it for full-bleed content. Presentational and server-renderable.

### SplitPane

**`ds-split-pane`** — Two resizable regions with a draggable divider: sidebar and canvas, list and detail, editor and preview. The split is a percentage of the first pane riding a `--ds-split-pane-split` custom property, so it survives container resizes; panes clip their content (`overflow: hidden`, `min-width/height: 0`) rather than growing the page, and a region that should scroll brings its own focusable scroll container (a bare scrollable pane would fail axe's scrollable-region-focusable rule, and a pane that is always a tab stop would be worse). The divider is a `--gap-200` hit area (transparent, tinting to `--color-bg-container-secondary` on hover and drag) holding a `--radius-pill` grip bar in `--color-bg-container-border` that darkens to `--color-icon-secondary` when engaged; both transition at `--motion-duration-fast`. It is a real `role="separator"`: focusable, `aria-valuenow` reporting the percentage, arrow keys nudging by 2 (Shift for 10), Home/End jumping to `minSplit`/`maxSplit` (default 10–90), and the focus ring is the standard `--color-action-primary-bg` outline. Pointer drags use capture so a fast drag cannot escape the handle, and `user-select` is suppressed while dragging. Controlled (`split` + `onSplitChange`) or uncontrolled (`defaultSplit`); `direction="vertical"` stacks the panes.

### AppLayout / AppSidebar

**`ds-app-layout`** — Full-page shell: header + collapsible sidebar + main content area. Pins `data-theme="dark"` by default (the historical behaviour); `theme="inherit"` drops the pin so the layout follows the surrounding theme. **`ds-app-sidebar`** — The side navigation component. Sidebar background: `--color-bg-page-primary`. Nav pills hold one inset in both states (12px margin + 8px padding), which makes the collapsed hover pill a circle clear of the 64px rail's edges and keeps every icon stationary through the expand/collapse. The transition choreography is deliberate: inner layout never changes between states — labels keep natural width with `min-width: 0` so they truncate under the sweeping rail, gaps are constant, and only the rail's clipping width, explicit widths (toggle, profile-more), and opacity fades animate; `width: 0 ↔ auto` and gap snaps read as a bounce and are never reintroduced. `floating` renders the rail as a glass card inset from the viewport edges (`--color-bg-glass` + 24px backdrop blur, `--radius-600`, `--shadow-floating`, inset via `--ds-sidebar-float-inset`, default 20px). Items take an optional `badge` count pill (hidden while collapsed); `topSlot` (under the logo row) and `footerSlot` (above the profile) host consumer content that fades out while collapsed without changing the rail's layout. The profile row composes `Avatar` at `md`, so a profile with no `avatarUrl` falls back to the person's initials and then to the generic glyph rather than showing the blank disc the hand-rolled box drew before it. An item or sub-item with an `href` renders as a real `<a>` (its `onClick` still fires, for client-side routing); accordion rows stay buttons. The active row carries `aria-current="page"`. In the tablet band (769-959px) entering the band folds the rail to the icon rail once, through the same path a click takes so controlled hosts hear it — entry-only, so the visitor can re-expand by hand. Below the mobile breakpoint (768px — the Breakpoints section owns the number) the rail leaves the layout and a fixed hamburger trigger opens it as a **modal overlay drawer** on the shared behavior layer (dismissal stack, focus trap, counted scroll lock): the full expanded layout slides in from the left over `--color-scrim`, always expanded — an icon rail inside a modal would be labels withheld for no gain. While the drawer is open the logo row's collapse control becomes its close button — a `close` glyph labelled "Close navigation" — because a modal drawer has no icon rail to collapse to; it is driven by the drawer's own open flag, which can only be true below the breakpoint. The trigger and scrim exist only inside the mobile media query, so the overlay can never activate on wider viewports, and crossing back above the breakpoint while open closes it. `showMobileTrigger={false}` hides the built-in trigger for hosts that render their own in the page chrome.

### ShaderField

**`ds-shader-field`** — A WebGL2 canvas summing soft Gaussian light sources into an ambient field of colour. Each source names a semantic colour token, read from the canvas's computed style at runtime, so the field re-themes with `data-theme` and with any scoped override — a consumer's palette reaches it for free. Colours are decoded to linear RGB and blended there, so midpoints keep their saturation; theme crossfade is `--motion-duration-slow`, and the reveal is `--motion-duration-slower` / `--motion-ease-entrance`, matching the page's own entrance.

The component owns the canvas and nothing else: `position: absolute; inset: 0` in whatever positioned ancestor the caller provides, `pointer-events: none` so a full-bleed layer never swallows a click. Placement, clipping and any mask belong to that ancestor — baking one in would make it unusable anywhere but a page background. **The default composition never samples `--color-action-primary-bg`**: a full-viewport decorative field is exactly the use that would dilute the action colour, so the last source takes `--color-core-ui-secondary`.

Eight parameters — `intensity`, `warp`, `scale`, `speed`, `grain`, `streak`, `react`, `crop` — pass as a partial merged over the defaults. `streak` stretches sources along a fixed diagonal, discs at 0 to light streams at 1. `grain` doubles as the dither that kills 8-bit banding, so it is never truly off. `react` is the cursor wake, shipping at 0 — built and dormant, not absent; raising it steps the loop from 30fps to 60fps only while a wake is alive. `crop` decides what a narrow container does to the composition: at 0 — the default, and what a CSS blob layer does — the whole field is fitted into it, so a phone shows every source shrunk to phone scale; at 1 the field holds the 1440px scale it was composed at and the viewport crops into it instead. Values between blend the two, keeping enough shrink that colours still bleed together on a small screen. Raise it for a full-viewport background (the site runs 0.5), leave it at 0 wherever seeing the whole composition is the point. A negative source `weight` absorbs light, cutting a shadow that occludes whatever shines behind it. `BLOB_COUNT` (8) sizes the shader's uniform arrays as a compile-time constant; passing fewer sources parks the unused slots off-field.

Two behaviours are the component's rather than the caller's, and both are why it can be dropped in. **It honours reduced motion in JavaScript** — the `tokens-motion.css` guard cannot see a `requestAnimationFrame` loop, so this draws one static frame and never starts the loop (see **Do's and Don'ts**). **And it always reports how it resolved**, via `onStatusChange` and a `data-status` attribute: `pending`, `active`, `unavailable`. No WebGL2, a blocked or lost context, a compile failure, a stalled first frame, and `enabled={false}` all land on `unavailable`, so one fallback covers every failure. `pending` is the state worth handling: paint neither, since swapping a fallback out a frame later reads as two backgrounds in sequence — a 1.5s watchdog bounds the wait so it can never be terminal. It renders no fallback of its own; what to paint instead is a design decision, not a rendering one. The canvas is `aria-hidden`.

### Toolbar

**`ds-toolbar`** — A grouped strip of controls sharing one pill shell: Buttons, CircularButtons, SegmentedControls, split into clusters by `ToolbarSeparator` hairlines (`--border-025` on `--color-divider`, stretched to the control height with `--gap-050` margins). The shell is `--radius-pill` on `--color-bg-container-primary` with a `--color-bg-container-border` hairline, `--gap-100` between controls, and `--padding-150` all round — the padding clears the focus ring's full reach (2px offset + 2px outline), so a focused control's ring seats inside the shell instead of slicing its border. `variant="floating"` is the glass pill for controls hovering over content — `--color-bg-glass`, a 24px frost (a component-local effect length), `--shadow-floating` — deliberately unpositioned: the consumer's layout decides where it floats (the site's StageControlBar is that consumer's shape). `orientation="vertical"` turns the pill into a capsule and rotates the separators. Announces as `role="toolbar"` with a required accessible `label`; arrow keys walk focus between the controls (Home/End jump to the ends) while every control keeps its natural tab stop, and a control that owns its arrow keys (a SegmentedControl) keeps them by preventing default before the event bubbles.

### Nav

**`ds-nav`** — Top navigation bar: a 78px-tall, max-width 1440px flex row with a brand slot (icon + `--font-paragraph-emphasis-*` text, `--gap-500`) on the left and a horizontal ButtonGroup plus optional `trailing` content (e.g. the theme ToggleSwitch) on the right at `--gap-1000`. Purely compositional — active states come from the Button configs passed in.

### NavList

**`ds-nav-list`** — Vertical navigation link list for drawers and side menus, up to three levels deep. Every row is a real link rendered as a full-width, left-aligned **tertiary Button** (level 1 at the default size, deeper levels compact), so shape, padding, typography, and hover/pressed states have one home in the Button spec and stay identical to every other button. The current page gets `aria-current="page"` plus the tertiary active background (`--color-action-passive-bg-active`) — the same treatment the showcase sidebar uses. A collapsible item adds a separate 40px `--radius-pill` chevron toggle (`expand_more` at `--icon-size-500`, rotating 180° when open) wired with `aria-expanded`/`aria-controls` — tapping the label navigates, tapping the chevron expands. Every row reserves that same 40px height, so leaf rows and toggle rows sit on one vertical rhythm at the list's `--gap-050` spacing. Sublists indent one `--padding-1000` step (then `--gap-500`), take the same `--gap-050` below their parent row, animate open via `grid-template-rows: 0fr → 1fr` at `--motion-duration-slow` / `--motion-ease-emphasized`, and are `inert` while collapsed so their links leave the tab order. Expansion is uncontrolled (`defaultExpandedIds`) or controlled (`expandedIds` + `onExpandedChange`), single-open by default; `collapsible: false` renders a permanent, always-visible group.

### AnchorNav

**`ds-anchor-nav`** — "On this page" list of anchor links for long pages. Optional header row (Material Symbol at `--icon-size-500` + `--font-paragraph-emphasis-*` title) above a list carried on a `--border-025` `--color-divider` left rail. Links are `--font-paragraph-*` in `--color-text-tertiary` with `--padding-150`/`--padding-400` padding; the active link turns `--color-text-primary` at em weight, paints a `--border-050` indicator in `--color-text-primary` over the rail, and carries `aria-current="location"`. Uncontrolled, a scroll listener marks the last section whose top passed the `offset` prop; pass `activeId` to control the highlight instead. Focus-visible ring in `--color-action-primary-bg`.

The `floating` variant trades the inline rail for a minimap that costs no column width: a right-aligned stack of one `--border-050`-tall, `--radius-pill` line per section (16px in `--color-divider`; the active line stretches to 24px in `--color-text-primary`) at `--gap-200` spacing inside a `--padding-200` hit area. Hover, keyboard focus, or a tap expands the full header + list as a panel over the lines — Popover's floating-surface recipe (`--radius-200`, `--border-025` `--color-bg-container-border` hairline, `--color-bg-page-primary` fill so nothing shows through, `--shadow-floating`), right-anchored and vertically centred, capped at 70vh with internal scroll, sliding in one `--gap-050` at `--motion-duration-fast` / `--motion-ease-standard`. The links stay focusable while collapsed (opacity, never `visibility: hidden`), so tabbing in opens the panel via `:focus-within`; Escape or a click elsewhere closes a tapped-open one. The lines are `aria-hidden` decoration — the list is the semantics in both variants. The caller positions the nav itself, typically fixed on the right page edge.

### Breadcrumb

**`ds-breadcrumb`** — Location trail as a `<nav aria-label="Breadcrumb">` ordered list. Items are links in `--font-paragraph-sm-*` `--color-text-secondary` separated by `chevron_right` Material Symbols at `--icon-size-500` in `--color-text-tertiary` (the 20px step matches the 20px line-height of the labels either side, so the chevron centres on the trail); the current (last) item is plain text in `--color-text-primary` at the `-em` weight. `maxItems` collapses the middle of long trails to an ellipsis, always keeping the first and the trailing items. Link focus rings use `--color-action-primary-bg`.

### Tabs

**`ds-tabs`** — Tabbed interface with `TabsList`, `TabsTrigger`, and `TabsContent`. Active tab trigger: teal border-bottom or teal fill depending on variant. Use for grouping related content on a single page (e.g., component page showing Light/Dark/Mobile views).

### Accordion

**`ds-accordion`** — Collapsible content sections in a single `--radius-200` bordered container (`--border-025` on `--color-bg-container-border`, row dividers inside). Headers are `--font-paragraph-emphasis-*` buttons with a rotating `expand_more` chevron and `--color-action-passive-bg-hover` on hover; panels hold arbitrary content at `--font-paragraph-*`. Single-open by default, `multiple` allows several; `defaultExpanded` seeds initial state. Headers carry `aria-expanded` and are wired to their panels.

### TreeView

**`ds-treeview`** — Collapsible hierarchy for files, folders, and nested structures, rendered as a WAI-ARIA tree (`role="tree"` on the root `<ul>`, `role="treeitem"` on each `<li>`, children in `role="group"`). Rows carry a `chevron_right` chevron that rotates a quarter turn on expand (`--motion-duration-fast` / `--motion-ease-standard`; leaves render a spacer in the chevron column so labels align), a Material Symbol at `--icon-size-500` (`folder` default for branches, `draft` for leaves, per-node `icon` overrides), and a `--font-paragraph-sm-*` label that ellipsises rather than wraps. Indentation is a per-depth custom property: each row's `--ds-treeview-depth` steps `padding-left` by one `--gap-400`. Hover takes `--color-action-passive-bg-hover`; the selected row (`ds-treeview__row--selected`) takes `--color-action-passive-bg-active` with the label bumped to `--font-paragraph-sm-emphasis-weight`; rows round at `--radius-200`. The tree items themselves are focusable via a roving tabindex (one tab stop, never nested buttons): ArrowDown/ArrowUp walk the visible rows, ArrowRight expands or steps into children, ArrowLeft collapses or moves to the parent, Home/End jump to the ends, and Enter or Space selects (click on a branch row also toggles it). Expansion (`expandedIds`/`defaultExpandedIds`/`onExpandedChange`) and selection (`selectedId`/`defaultSelectedId`/`onSelect`) each follow the controlled/uncontrolled pair convention. Each treeitem is named by `aria-labelledby` pointing at its own label, so a branch never announces its whole subtree as its name; expanded state styling keys off `aria-expanded`, so visuals and semantics cannot desync.

### Carousel

**`ds-carousel`** — Sliding content viewport. Navigation arrows are 36px `--radius-pill` bordered buttons on `--color-bg-page-primary` (no shadow — the arrows are not floating surfaces); dot indicators mark the active slide with `--color-action-primary-bg`. Supports `autoPlay` with configurable interval, `loop`, keyboard navigation (arrow keys), and an `onSlideChange` callback. Autoplay pauses on hover or keyboard focus and never starts under `prefers-reduced-motion` (a JS-side check; see Do's and Don'ts). The dots are a roving-tabindex `role="tablist"`: one tab stop, arrows move and select. Slides are the component's children.

### ImageCompare

**`ds-image-compare`** — Before-and-after comparison of two images sharing one frame: a `--radius-400` container on `--color-bg-container-secondary` under a `--border-025` `--color-bg-container-border` hairline, sized by `aspectRatio` (default 16 / 10) with both images covering it. The after image is the base layer; the before layer sits on top clipped by `clip-path` to the divider position, so the two stay pixel-aligned as the split sweeps. Dragging anywhere in the frame moves the split (pointer capture, `touch-action: pan-y`, so vertical page scroll survives over the frame on touch); the handle — a 32px `--color-bg-page-primary` disc under `--shadow-floating` riding a `--border-050`-wide divider line — is a `role="slider"` with arrow keys (±1), PageUp/PageDown (±10), and Home/End. Corner labels (`beforeLabel`/`afterLabel`, defaults Before/After, hidden via `showLabels`) render as inverse pills in `--font-paragraph-sm-emphasis-*`. Position is controlled (`position`) or uncontrolled (`defaultPosition`, 50) with `onPositionChange`.

### Table

**`ds-table`** — Data table with header row and body rows; presentational only (no sorting, selection, or row expansion). Header: `--font-title-body-*` (weight 600). Body: `--font-paragraph-*`. Row dividers: `--border-025` in `--color-bg-container-border` by default; the `bordered` variant's header and body cells switch to `--color-divider`. The plain table takes the background of whatever it sits on. `bordered` wraps it in a `--border-025` `--color-divider` container with `--radius-300` filled `--color-bg-container-primary` — the same ramp step as the cards and panels beside it, so a bordered table stands off the page instead of dissolving into it — with the header band one notch further down the ramp at `--color-bg-container-secondary` and row hover on `--color-action-passive-bg-hover`, a translucent tint that reads over the body surface in both themes.

### DataTable

**`ds-data-table`** — The wired version of Table: sorting, search, row selection, and client-side pagination assembled around the presentational pieces (Table `bordered`, Pagination `compact`, Checkbox, Input, EmptyState), so a working data view is one component rather than an afternoon of plumbing. Rows carry raw values keyed by column; a column's `render` turns them into cells (Badges, Buttons) while sorting and search keep reading the raw value underneath. Chrome only in this component's own CSS — rows and cells are entirely Table's.

Sortable headers render the label inside a button with a trailing sort glyph (`swap_vert` idle, `arrow_upward`/`arrow_downward` active, `--icon-size-500`) that fades in on hover/focus and stays at full opacity while active; clicks cycle ascending → descending → unsorted, and the button's `aria-label` narrates the current state. Sorting compares numbers numerically and text with `localeCompare` (numeric-aware). The toolbar puts a consumer `toolbar` slot (filter Dropdowns) on the left and the built-in search Input (`compact`, `search` icon, capped at 240px) on the right; searching matches every column's raw value and resets to page 1. Selection adds a 40px checkbox column with an indeterminate select-all scoped to the visible page. The footer pairs a `tabular-nums` readout (`--font-paragraph-sm-*` tertiary; result count, or "N selected" once a selection exists, in an `aria-live` region) with compact Pagination. No matches renders the bordered EmptyState. Sort and selection follow the controlled/uncontrolled pair convention (`sort`/`defaultSort`/`onSortChange`, `selectedIds`/`defaultSelectedIds`/`onSelectionChange`) so server-driven tables stay possible.

Row status is shown as a Badge, with editing behind the row's actions — the blessed pattern (2026-08-26). A live select in every row also works and some references use it, but a page of mounted comboboxes is heavy and the table reads calmer with the state as a label; reach for per-row selects only when in-place switching is the row's primary job.

### Dialog

**`ds-dialog`** — General-purpose modal for arbitrary content; for confirm/cancel prompts use AlertDialog. Panel: `--radius-300`, `--color-bg-page-primary`, hairline `--color-bg-container-border` border, `--shadow-modal`, over a `--color-scrim` backdrop; opens with the standard base-duration scale + fade (`--motion-duration-base` / `--motion-ease-standard`). Header: `--font-heading-3-*` title with optional `--font-paragraph-sm-*` tertiary description and a 32px ghost close button. Body slot scrolls (`overflow-y: auto`) when content exceeds the viewport-capped panel height; optional footer slot right-aligns consumer-provided Buttons. Sizes: `sm` 400px / `md` 560px (default) / `lg` 720px max-width. Behaviour: portal to `<body>`, `role="dialog" aria-modal="true"`, and the shared overlay contract (see the Components intro): stacked Escape, focus trap and guarded restore, inert background, counted scroll lock. `dismissible={false}` disables ESC, backdrop click, and hides the close button — the layer stack swallows Escape rather than passing it to an overlay beneath.

### AlertDialog

**`ds-alert-dialog`** — Modal confirmation for important or destructive actions. A portal-rendered overlay: `--color-scrim` backdrop behind a `--radius-300` panel on `--color-bg-page-primary` with `--border-025` border and `--shadow-modal`, title + description text, and a Cancel/Confirm Button pair. `variant="destructive"` styles the confirm action with the error tokens. Follows the shared overlay contract (see the Components intro) — stacked Escape cancels, focus trap, guarded restore, inert background, counted scroll lock — with initial focus landing on the Cancel button, so the safe action is one keypress from the destructive one; the panel wires `aria-labelledby`/`aria-describedby` to the generated title/description ids. SSR-safe (portal only mounts client-side). For general-purpose modal content use Dialog; AlertDialog is only for confirm/cancel decisions.

### Drawer

**`ds-drawer`** — Edge-anchored modal panel. Shares Dialog's modal contract (portal to `<body>`, `role="dialog" aria-modal="true"`, the shared overlay contract from the Components intro, `dismissible={false}` to disable ESC/scrim/close) but slides in from a viewport edge instead of scaling from centre. `side` picks the edge (`left`/`right`/`top`/`bottom`); the panel rests off-screen via a `translate` transform and returns to zero when open, over a `--color-scrim` backdrop that cross-fades. Surface: `--color-bg-page-primary` with `--shadow-modal` and a hairline `--color-bg-container-border` on the edge facing the page. Header is a `--font-heading-3-*` title with optional `--font-paragraph-sm-*` tertiary description and a 32px ghost close button; the body scrolls independently so header and footer stay pinned; the footer right-aligns consumer Buttons. Sizes measure along the slide axis — `sm`/`md`/`lg` are 320/420/560px for side drawers and 30/50/75vh for top and bottom. Side drawers go full-width under 480px. Use Dialog for centred, self-contained prompts; Drawer for filter panels, detail views, and mobile navigation.

### CommandPalette

**`ds-command-palette`** — Modal Cmd+K launcher over a grouped command list. Panel is a 560px glass card — `--color-bg-glass` behind the documented 24px backdrop blur, at the floating-surface `--radius-600` with a `--border-025` `--color-bg-container-border` hairline and `--shadow-floating`, the same recipe as the site chat panel and AppSidebar's floating variant — pinned 10vh from the top over a `--color-scrim` backdrop, capped at 60vh. A search row (24px `search` icon, borderless input, 32px ghost close) sits above a scrolling list of `--radius-200` command rows; the input takes focus on open (the closed state's `visibility` flips instantly on open and only delays on close, so the focus always lands), and while it holds focus the row's bottom border takes `--color-input-border-selected`, the same active signal a focused Input gives its own edge; each row takes an optional 20px icon, a `--font-paragraph-emphasis-*` label, an optional tertiary description line, and a `shortcut` array rendered as compact Kbd keycaps (see Kbd); a `trailing` node renders at the row's right edge after any keycaps — a badge or affordance the keycap slot can't express, kept non-interactive because it sits inside the option row (the site palette's ask-the-chat row carries the AI-ring chip this way). Group headings use `--font-paragraph-sm-*` tertiary; the active row takes `--color-action-passive-bg-hover`. Filtering matches label, description, and `keywords`; disabled commands stay visible but are skipped by the highlight. Keyboard: arrows wrap through the flattened list, Home/End jump to the ends, Enter runs, Escape closes, and `hotkey` binds Cmd/Ctrl+K globally (set false when the host app owns the shortcut). Shares Dialog's modal contract (the shared overlay contract from the Components intro): stacked Escape working dialog-wide, focus trap, guarded restore, inert background. A footer hint row documents those keys and hides under 480px. Closed state uses `visibility: hidden` so the input never enters the tab order. The list scrolls with a quiet scrollbar (`scrollbar-width: thin`, `--color-divider` thumb, stable gutter, so filtering toggling overflow cannot reflow the rows). The page behind is locked by cancelling scroll input — wheel and touch pass only inside the list (`overscroll-behavior: contain` stops end-of-list chaining) — never by hiding overflow, so the document scrollbar stays rendered and the page never shifts or blinks on toggle.

### Lightbox

**`ds-lightbox`** — The fullscreen media viewer: an image or custom media centred over a deep frosted scrim, with a caption chip, a close control, and optional previous/next stepping for galleries. Rides the shared overlay behavior layer like the other modals — Escape through the layer stack, focus trapped and restored with the page inert, the counted scroll lock — while gallery stepping (chevrons plus the arrow keys, armed by `onPrev`/`onNext`; the consumer owns the collection and index) stays the component's own listener, gated on open state. The scrim is `--color-scrim` laid twice (≈0.75 light / ≈0.91 dark — a media viewer wants a deeper floor than the dialogs', without a second scrim role) over a 12px backdrop frost. Media caps at the viewport minus `--gap-1000` margins at `--radius-300` on `--shadow-modal`, and springs from 0.96 scale on `--motion-ease-entrance`. The furniture — caption chip and the three `--icon-size-1200` circular controls — lives in scrim-world rather than page-world: a white-alpha wash with white glyphs, sanctioned in place with `ds-allow(color)`, because the scrim floor is dark in both themes and any theme token would dissolve into it in dark mode; the controls also sit at `z-index: 1` so a full-width image on a phone cannot swallow the leading chevron. The panel is a full-viewport stage transparent to clicks (`pointer-events: none`, children `auto`), so clicking anywhere off the media reaches the backdrop and dismisses.

### Popover

**`ds-popover`** — Anchored contextual overlay. Wraps a trigger and positions a `--radius-200` panel (min-width 200px, `--padding-400`, `--color-bg-page-primary` like the other floating menus and panels — Dropdown, DropdownMenu, ContextMenu, ModelPicker — `--border-025` border, `--shadow-floating`) on the chosen side (`top`/`bottom`/`left`/`right`). Trigger mode is `click` (outside-click and ESC dismiss) or `hover`; open state can be controlled via `open`/`onOpenChange`. Content is arbitrary ReactNode — unlike Tooltip, which is text-only. Sizes: `default`, `compact`.

### HoverCard

**`ds-hover-card`** — Rich preview panel on hover or focus: a profile behind a username, a component summary behind a link. Sits between Tooltip and Popover — it opens like Tooltip (hover or focus with the shared `MOTION_HOVER_SHOW_DELAY_MS`/`MOTION_HOVER_HIDE_DELAY_MS` timings, Escape dismisses) and dresses like Popover's floating-panel family (`--radius-200` panel on `--color-bg-page-primary`, `--border-025` hairline, `--shadow-floating`, `--padding-400`, arbitrary ReactNode content, min-width 240px / max-width 320px). Unlike Tooltip the panel is interactive: it stays open while the pointer or focus is inside it, so links and buttons in the preview are reachable. Hidden state is `display: none` (transitioned discretely, so the fades survive), keeping the closed panel out of the accessibility tree, the tab order, and the page's scrollable overflow — Tooltip and Popover hide the same way, so an edge-adjacent trigger's invisible panel can never add horizontal scroll; the trigger is cloned with `aria-expanded`/`aria-controls`. Positions: `top`/`bottom`/`left`/`right`.

### DropdownMenu

**`ds-dropdown-menu`** — Action menu opened from a trigger element (contrast with Dropdown, which is a form select). The panel is `--radius-300` on `--color-bg-page-primary` with a `--border-025` `--color-input-border-primary` hairline and `--shadow-floating`, `--padding-100` inset, aligned `start` or `end`. Entries are a typed tree: items (label, optional Material Symbol icon, keyboard `shortcut` hint, `disabled`, `destructive` — red via the error/coral tokens), `separator`s, labelled `group`s, and nested sub-menus via `children`. Full keyboard navigation across the flattened item list; hover uses `--color-action-passive-bg-hover`. The menu semantics are cloned onto a consumer-supplied trigger element, and the consumer's own `onClick`/`onKeyDown` compose with the menu's — theirs fire first, never overwritten. Sizes: `default`, `compact`.

### ContextMenu

**`ds-context-menu`** — Right-click (and keyboard ContextMenu / Shift+F10) menu anchored at the pointer position. Wraps its `children` as the right-clickable area; the panel is `position: fixed` at the event coordinates, clamped inside the viewport, and closes on outside click, scroll, resize, Escape, or item activation. Entries reuse DropdownMenu's typed tree (`DropdownMenuEntry`: items with icon/`shortcut`/`disabled`/`destructive`, `separator`s, labelled `group`s, one level of `children` sub-menus) and the exact same panel recipe — `--radius-300` on `--color-bg-page-primary`, `--border-025` `--color-input-border-primary` hairline, `--shadow-floating`, `--padding-100` inset, `--motion-duration-fast` appear. Keyboard: the panel takes focus on open (returning it on close) and tracks the highlighted item with `aria-activedescendant`; arrows move, Home/End jump to the ends, Enter activates, Escape closes. Sizes: `default`, `compact`. Use DropdownMenu when the menu opens from a visible trigger; ContextMenu when it opens on the content itself.

### Tooltip

**`ds-tooltip`** — Text-only contextual label on hover or focus. The bubble rides the floating surface, the same recipe as Popover: `--color-bg-page-primary` with `--color-text-primary`, a `--border-025` hairline in `--color-bg-container-border` and `--shadow-floating`, at `--radius-100`, `--font-paragraph-sm-*` — same-polarity in both themes (light bubble on light, dark on dark; the old inverse fill read too stark). The rotated-square arrow shares the fill and draws the hairline on its two outward edges. Four positions (`top`/`bottom`/`left`/`right`) with a 4px slide-in transition; `showDelay`/`hideDelay` control timing (300/150ms default). Escape dismisses a visible tooltip from anywhere on the page (WCAG 1.4.13). The panel has `role="tooltip"` and an id; Tooltip clones its child element with `aria-describedby` pointing at that id — host elements get it automatically, and Button/CircularButton accept the attribute natively. Content is a string; anything richer belongs in Popover.

### Divider

**`ds-divider`** — Thin rule separating stacked content: `--border-025` (1px) in `--color-divider`. Plain horizontal renders a semantic `<hr>`; a `label` variant sets text inline in the line (`--font-paragraph-sm-*` in `--color-text-secondary`, `center` or `start` position, `role="separator"`); `vertical` stretches to container height inside flex rows (`aria-orientation="vertical"`). Spacing prop maps to the gap scale: `none`/`sm` (8px)/`md` (16px, default)/`lg` (20px) — block margin when horizontal, inline when vertical. Not for separating page sections under `h2` headings — the `h2` bottom border already does that (see Section Dividers above); Divider is for forms, lists, toolbars, and card interiors.

### Pagination

**`ds-pagination`** — Page navigation for long datasets; pairs with Table. A `<nav>` of pill page buttons (40px, `--radius-pill`) with chevron arrows at each end; first and last pages always visible, ellipses cover the gaps (`siblingCount` controls the window, default 1). Current page takes the SegmentedControl active treatment: `--color-action-primary-bg` fill with `--color-action-primary-text`, `aria-current="page"`. Idle buttons: `--font-paragraph-emphasis-*` in `--color-text-secondary`, hover `--color-action-passive-bg-hover`. Arrows disable at the ends (`opacity: 0.4`, `cursor: not-allowed`). `size="compact"` swaps the numbers for a "Page X of Y" readout (`--font-paragraph-sm-*`) between 32px arrows.

### Stepper

**`ds-stepper`** — Step-by-step progress indicator for wizards and multi-stage flows. An `<ol>` of steps with CSS-drawn connectors; the active item carries `aria-current="step"`. Indicators are 32px `--radius-pill` circles: complete shows a `check` Material Symbol at `--icon-size-500` in `--color-action-primary-text-tertiary` inside a `--color-action-primary-border-secondary` outline, active takes the `--color-action-primary-bg` fill with `--color-action-primary-text`, upcoming shows its number in `--color-text-tertiary` on a `--color-bg-container-border` outline. Labels use `--font-paragraph-sm-emphasis-*` (active `--color-text-primary`, complete `--color-text-secondary`, upcoming `--color-text-tertiary`); optional descriptions are `--font-paragraph-sm-*` in `--color-text-tertiary`. Connectors are `--border-050` lines in `--color-divider`, turning `--color-action-primary-border-secondary` behind completed steps. With `onStepClick`, completed and active steps become buttons (indicator hover `--color-action-passive-bg-hover`, focus ring `--color-action-primary-bg`); upcoming steps stay non-interactive. `orientation="vertical"` stacks the list with connectors dropping below each indicator. Colour changes transition with `--motion-duration-base` and `--motion-ease-standard`.

### Stat

**`ds-stat`** — A single headline metric: display-weight numeral over a quiet label, with an optional trend delta. Value uses `--font-sub-display-*` (30px/300) by default, `--font-display-2-*` (64px/300) at `large` — the weight-contrast rule applied to numerals. Label: `--font-paragraph-sm-*` in `--color-text-tertiary`. Delta: `--font-paragraph-sm-emphasis-*` with an `--icon-size-500` (20px) Material arrow; colours by trend through the theme-split trend tokens — `up` → `--color-trend-up`, `down` → `--color-trend-down`, `neutral` → `--color-text-tertiary` (the earlier always-accent colouring was unreadable on light surfaces; see the Trend token section). `deltaPlacement="inline"` (`ds-stat--delta-inline`) moves the delta to the right of the value, bottom-aligned to its baseline via a grid re-template, for dashboard KPI tiles; the default stacks it below the label. Compose several in a flex row for a metrics band.

### AnimatedNumber

**`ds-animated-number`** — A number that counts to its value: from zero on mount, and from the previous value when `value` changes, easing out (cubic) so the landing reads as arrival rather than a stop. It owns no face, size, or colour — the component is a number inside someone else's composition (a Stat, a table cell, a sentence) and inherits the surrounding type entirely; its one typographic opinion is `font-variant-numeric: tabular-nums`, so the layout holds still while the digits roll. The tween budget is `MOTION_COUNT_UP_MS` from `tokens/motion.ts` (overridable per instance via `duration`) — an animation pace, not a schedule timing, so the component checks reduced motion itself in JS and snaps straight to the value. Formatting is `decimals` (locale-grouped via `toLocaleString`) or a custom `format` function called per frame. Screen readers hear only the settled value: the rolling digits are `aria-hidden` behind a visually hidden span carrying the target.

### CodeBlock

**`ds-code-block`** — Monospace code in a `--color-bg-container-primary` container with `--radius-300` and a hairline border. The original sanctioned monospace context in the system, set in `--font-family-code` (Nunito Sans everywhere outside code contexts). Optional header row: filename (mono, `--color-text-secondary`), uppercase language tag (`--color-text-tertiary`, 0.08em tracking), and a copy button that confirms with a check for 2s. Code text is 14px/20px, `--color-text-primary`; long lines scroll horizontally. An optional `maxHeight` prop caps the block: the code area scrolls vertically inside while the header stays pinned. An optional `collapsible` prop adds a chevron beside the filename (`--color-icon-primary`, 20px, rotates −90° when closed) that collapses the code area with the same 0fr/1fr grid animation as Accordion; `defaultCollapsed` starts it closed. No syntax highlighting — monochrome by design, no dependencies.

### CodeDiff

**`ds-codediff`** — Unified diff view for code changes, in the same shell as CodeBlock (a `--color-bg-container-primary` container with `--radius-300` and a hairline border, code set in `--font-family-code` at 14px/20px). The exported `parseUnifiedDiff` turns the diff string into typed lines: `+` additions, `-` removals, `@@` hunk headers, everything else context. File header lines (`+++`, `---`) and `\ No newline` markers are skipped, and a diff with no hunk headers is treated as one hunk numbered from line 1. Added rows tint `--color-status-positive-bg` and removed rows `--color-status-error-bg`, with the `+`/`-` marker in the matching status text colour; hunk rows sit on `--color-bg-container-secondary` in `--color-text-tertiary`. Two gutters track old and new line numbers (`--color-text-tertiary`, excluded from text selection so a copied diff carries only code); `showLineNumbers` toggles them, and an optional `filename` header adds a "+N -N" summary in the positive and error text tokens. The marker and gutter columns are `aria-hidden` and the change type is announced through visually hidden text; long lines scroll horizontally inside a keyboard-reachable labelled region. Purely presentational, no `'use client'`, no syntax highlighting.

### Kbd

**`ds-kbd`** — A single keyboard key rendered as a keycap, for shortcut hints in menus and docs prose. A semantic `<kbd>` on a `--color-bg-container-primary` chip with a `--border-025` `--color-bg-container-border` border at `--radius-100`; legend in `--font-paragraph-sm-*` at em weight, `--color-text-tertiary`, centred with a min-width so single letters stay square-ish (24px default, 20px compact). Sizes: `default`, `compact` (the compact size matches CommandPalette's shortcut hints, which render through Kbd). Purely presentational — no `'use client'`; compose several for a chord: `⌘` + `K`.

### Quote

**`ds-quote`** — Blockquote with optional attribution, two registers. `default`: body-size text in `--color-text-secondary` behind a `--border-050` left rule in `--color-bg-container-tertiary`. `pull`: `--font-sub-display-*` (30px/300) in `--color-text-primary`, no rule — the scale is the emphasis. Attribution: em-dash + `--font-paragraph-sm-emphasis-*` primary; detail line: `--font-paragraph-sm-*` tertiary. Renders semantic `<figure>/<blockquote>/<figcaption>`.

### Field

**`ds-field`** — The scaffolding shared by every labelled form control, so the wiring exists once rather than per component. Renders the `<label>` (`--font-paragraph-emphasis-*`, `--color-text-primary`) with its `htmlFor`, the required marker (`ds-field__required`, `--color-core-accent-coral`, `aria-hidden` — the control's real `required` attribute is what gets announced), the control itself as children, the helper/error `<p>` (`ds-field__helper`, `--font-paragraph-sm-*`, `--color-text-tertiary`), and an optional `aside` slot (character counters, units) in a `ds-field__footer` wrapper. Modifiers: `--error` recolours the helper to `--color-status-error-border`; `--disabled` dims the label to `--color-input-text-disabled`; `--compact` drops the label to `--font-paragraph-sm-*`.

Field deliberately owns **no layout** — the flex column and gap stay on the consuming component's own root class, so adopting it changes no spacing. It generates the control id when one isn't supplied, derives the helper id from it, and exposes `{ controlId, labelId, describedBy, invalid, required, disabled }` through `useField()`, which returns `null` outside a Field so a control still renders standalone. A `group` mode renders the label as a `<span id>` instead of a `<label htmlFor>` — a set of controls has no single labelable element, so the group node names itself with `aria-labelledby` pointing at it; CheckboxGroup and RadioGroup compose it that way. Input, Textarea, DateInput, Dropdown, RichDropdown, Combobox, FileInput, ColorPicker, NumberInput, PinInput, TagInput, TimePicker and Slider compose inside it directly; the inline choice controls (Checkbox, RadioButton, ToggleSwitch) keep their beside-the-control labels but carry the same `helperText`/`error` vocabulary themselves, and Rating, SegmentedControl, ToggleGroup and DatePicker stay outside.

### Figure

**`ds-figure`** — Image + caption in a `--radius-300` container with `--color-bg-container-secondary` fill. Accepts any image element as children (plain `<img>` or `next/image`); the media slot stretches it full-width. Caption: `--font-paragraph-sm-*` tertiary, `--padding-400`/`--padding-500`, hairline top border. Passing `onClick` makes it zoomable: the media wraps in a bare full-width `<button>` (`ds-figure__zoom` — ARIA forbids `role="button"` on `<figure>` itself, and a real button brings Enter/Space activation for free) with `cursor: zoom-in`, hover dimming the image to 0.88, and the teal focus ring drawn inset so the figure's overflow clip can't crop it.

### Avatar

**`ds-avatar`** — User identity circle (`--radius-pill`) in three sizes — `sm` 32px, `md` 40px, `lg` 56px. Renders the image when `src` is provided, falls back to initials derived from `name` on `--color-bg-container-secondary`, then to a person icon. An optional `status` dot (`online`/`away`/`offline`/`busy`) sits on the rim, filled with the matching status border token and ringed in `--color-bg-page-primary`. `role="img"` with a label from `alt`/`name`. One of the components using the `sm/md/lg` size vocabulary (with Spinner and Dialog) — intentional for components with three or more sizes.

### AvatarGroup

**`ds-avatar-group`** — An overlapping stack of Avatars with a "+N" counter for the overflow — the "who's here" affordance. Purely presentational (no `'use client'`): it wraps each child in a ring-bearing item (`--radius-pill`, a `--border-050` `--color-bg-page-primary` box-shadow ring so overlaps stay legible on any surface) and overlaps them by `--gap-200`. The group's `size` (`sm`/`md`/`lg`, Avatar's own 32/40/56px ladder) is cloned onto the children so a stack can never render mixed sizes, and sizes the counter circle to match — `--color-bg-container-secondary` fill, `--color-text-secondary` text at the same initials type ramp as Avatar. `max` (default 5) caps the visible avatars; the counter is `role="img"` with an accessible "N more" label, overridable via `overflowLabel`. The root is `role="group"` — pass `aria-label` to name the collection.

### ContactCard

**`ds-contact-card`** — Full-width contact row card: icon-or-logo slot, label (`--font-title-body-*`) with a truncating value line (`--font-paragraph-sm-*`, `--color-text-tertiary`), and a trailing affordance icon. `--radius-300` on `--color-bg-container-primary-semi` with a `--color-bg-container-border` hairline; hover deepens to `--color-bg-container-secondary`. Renders as an `<a>` (protocol links like `mailto:` never open a new tab even when `external`) or, in `copyOnClick` mode, as a `<button>` that copies the value — `copyable` instead adds a discrete copy button alongside. The trailing icon signals the behaviour: `arrow_forward`, `open_in_new`, or `content_copy`.

### LinkList

**`ds-link-list`** — Vertical list of links at `--gap-500`. Each row: a 28px logo image or Material Symbol, then a `--font-title-body-*` title with an inline indicator and optional `--font-paragraph-sm-*` subtitle lines in `--color-text-tertiary`. Rows default to `<a target="_blank">` with an `open_in_new` indicator; an item with `newTab: false` stays in the current tab and shows `arrow_forward` instead, for links inside the same site. Hover is an opacity dim. Data-driven via an `items` array — lighter-weight than a stack of ContactCards; used for publication and award lists and in-site link rails.

### SectionTitle

**`ds-section-title`** — The standard section heading used across the docs site: an `<h2>` in `--font-heading-2-*` `--color-text-secondary` with an optional `trailing` slot (count, badge, metadata) in `--font-paragraph-*` `--color-text-tertiary`, closed by a `--color-divider` bottom border with `--padding-500` breathing room. This is the h2-carries-the-divider rule from the typography spec, packaged as a component; the component owns only the heading-to-rule half of the header-divider rhythm (see Section Dividers) — the `--gap-1000` below the rule is the consuming page's job. `divider={false}` (`ds-section-title--no-divider`) drops the border and reduces the clearance to `--padding-400`, for headings above content that draws its own lines (bordered tables, calendars, the /overview map sections' diagram canvases) — where a second rule would double up, the section separates by whitespace alone. A page may keep the divider on its column-top headings while the sub-sections below drop theirs (the /overview arrangement): the top of a column earns the anchoring line, the line-heavy sections underneath do not.

### Instructions

**`ds-instructions`** — Step-by-step guidance list. Each step has a `--radius-pill` indicator badge — the step number, or a Material Symbol when `icon` is set — connected by hairline lines (`--color-bg-container-border`), with a `--font-paragraph-emphasis-*` label and optional `--font-paragraph-*` description. Renders as an `<ol>`; directions `vertical` (default) and `horizontal`; sizes `default`/`compact`.

### Timeline

**`ds-timeline`** — Ordered sequence (`<ol>`) with connected markers; `vertical` (default) for histories and process narratives, `horizontal` for compact steppers. Markers: 12px dot in `--color-bg-container-tertiary`; `numbered` upgrades to a 28px badge (`--color-bg-container-primary` fill, hairline border); a per-item `icon` renders as a bare 24px Material Symbol (no circle) centred in the same 28px box. Connector: `--border-050` line in `--color-divider`, hidden after the last item; it stands off below/after bare icon markers instead of passing behind them. Item anatomy: meta (`--font-paragraph-sm-*` tertiary) over title (`--font-heading-3-*`, 22px/600) over description (`--font-paragraph-*` primary, offset `--gap-200` below the title).

**`ds-timeline--company`** — a résumé/pipeline variant (`variant="company"`, always vertical). The marker is a 32px logo image (`ds-timeline__marker--logo`, bare transparent box) instead of a dot/icon, and each entry carries a company/tool name (`ds-timeline__company-name`, `--font-title-body-*`, 16px/600) beside it. Under the name sits one or more roles (`ds-timeline__role`): a header row (`ds-timeline__role-header`) with the role title (`--font-heading-3-*`) on the left and a right-aligned, optional date (`ds-timeline__role-dates`, `--font-paragraph-*` tertiary) — a current role sets `present` to render a green "Present" (`ds-timeline__present`, `--color-status-positive-text`) in place of the end date. An optional `subtitle` (`ds-timeline__role-subtitle`, `--font-paragraph-*` secondary) sits on its own line under that row for the team, org, or product the role sat in — keeping the title a job title rather than a title and a team punctuated together. Because the title and subtitle read as one header block, whatever follows a subtitle takes a `--gap-200` top margin on top of the role's own `--gap-200` rhythm, so the body separates from the header at twice the internal spacing. Roles may add an optional description (`--font-paragraph-*` secondary) and a disc bullet list (`ds-timeline__role-bullets`, links in `--color-action-primary-bg`). The connector is **segmented per entry** — centred under the 32px logo, starting 8px below it and running down to 8px above the next entry's logo, so every logo sits in an even break rather than having the line pass behind it. The last entry's bar thins to `--border-025` and fades to `--color-bg-container-primary-transparent` at its end — the site's fading-hairline treatment, marking where the timeline runs out.

### AreaChart / BarChart / ComboChart / LineChart / PieChart / RadarChart / RadialChart / ScatterChart / StackedBarChart / Treemap

The Recharts wrapper set, sharing one implementation folder (`Chart/`), one CSS file, and one visual language. Series colours come from the ordered `--color-chart-series-1` → `-7` ramp as live `var()` references emitted by the shared palette helper (`Chart/palette.ts`) — SVG paint resolves them at draw time, so charts follow a mid-session theme switch without re-rendering, and every multi-series chart assigns the same colour to the same slot. Series 1 aliases the action teal (`--color-action-primary-bg`) — the sanctioned data-viz exception to the action-only rule, theme-split with it — and series 2–7 alias the core accents (mint, gold, coral, violet, amber, cobalt), so re-theming an accent re-themes every chart using its slot. Tooltips float on the glass surface — `--color-bg-glass` with the documented 24px backdrop blur, a `--border-025` hairline in `--color-bg-container-border`, `--radius-200` and `--shadow-floating` — so the marks underneath stay legible through the overlay; tooltips and legends use system typography tokens. Axes text in `--color-text-tertiary`. Every chart animates its marks in on mount — bars grow, lines draw, arcs sweep, Recharts' own entrance choreography at its default pacing — and that animation runs from JavaScript, where the CSS token guard cannot reach; the JS-side reduced-motion check the motion contract requires is Recharts' own, not ours: every mark's `isAnimationActive` defaults to `'auto'`, which resolves to off under `prefers-reduced-motion` (and in SSR), so the preference lands the chart directly on its finished frame. Never pass an explicit boolean there without a reason — a literal `true` would override the guard. Every chart takes `bare` (`ds-chart--bare`), which strips the card chrome — border, padding, fill — for when the chart sits inside a Panel or card that supplies the surface (the composition rules' one-level-of-chrome principle as a prop). ComboChart pairs one bar series with one line series, on a shared axis or with `secondaryAxis` putting the line on a right-hand scale for pairs in different units (spend and ROAS). AreaChart's automatic multi-series legend can be dropped with `showLegend={false}` when legend tiles carry the series instead. RadialChart takes `centerLabel` (+ `centerSublabel`), a dial headline printed in the donut hole (distinct from the dependency-free `Gauge` component, which owns the single-reading dial) — pair it with `showLegend={false}`, since the legend shifts the rings above centre.

### Contribution graph

**`ds-contribution-graph`** — GitHub-style activity heatmap: weeks as columns, weekdays as rows, one 12px cell per day at `--radius-050`. It wears the chart family's shared chrome — the `ds-chart` card, header (`title`/`subtitle`), and padding come from `Chart.css`, and `bare` strips them for a graph inside a panel that supplies the surface (the site's embedded uses are all `bare`). Cell colour comes from the five-step contribution ramp, defined in both themes:

- `--color-chart-contribution-0` — no activity (`--color-bg-container-primary` light / #232323 dark)
- `--color-chart-contribution-1` → `-4` — increasing activity, green primitives (light: green-02 → 04 → 07 → 09; dark: green-10 → 09 → 08 → 07, so the brightest cell is mint #06D6A0)

Month labels, caption, and Less→More legend use `--font-paragraph-sm-*` in `--color-text-tertiary`/`--color-text-secondary`. The grid scrolls horizontally inside its own container on narrow screens. The year sweeps in left to right on mount — each column's cells fade in at `--motion-duration-slow`, delayed by a per-column fraction of `--motion-duration-slower` (token-composed, so reduced motion collapses the sweep) — and the day under the pointer takes a `--color-text-tertiary` inset outline so the native tooltip has a visible anchor. This ramp is for activity intensity only — ordered multi-series colours come from the `--color-chart-series-*` ramp (see the chart set spec above).

### Sparkline

**`ds-sparkline`** — An inline trend line for stats and table cells, drawn without axes or chrome. Dependency-free SVG computed from props, not a recharts wrapper: no `'use client'`, no hooks, so it renders from a Server Component and works without the optional recharts peer. It keeps its own folder rather than joining `Chart/` — a fit call, not a dependency rule: folder placement and recharts membership are independent axes, and FunnelChart is recharts-backed in its own folder. The series normalises into a padded viewBox (default 120x32; `width`/`height` set both the viewBox and the default rendered size, and the SVG scales to its container when sized via CSS), and the line bends through a monotone cubic curve — the same family as the recharts charts' `monotone`, so a sparkline reads as a miniature LineChart rather than a jagged polyline, and the curve never overshoots a local extreme. Colour flows through `color`: line, area fill, and end dot all draw with `currentColor`, set per tone. `accent` (default) uses `--color-action-primary-bg` (the chart palette's sanctioned lead colour), `positive` uses `--color-trend-up` and `negative` uses `--color-trend-down` (the same trend pair Stat's delta wears — stable hues in both themes, where the status text tints go near-white in dark mode and would wash the two tones into one), `neutral` uses `--color-text-secondary`. `variant="area"` (`ds-sparkline--area`) repeats the tone colour under the line at 0.15 opacity. `showDot` (default true) marks the final point with a circle sized off `strokeWidth` (default 2, an SVG geometry attribute rather than a token); internal viewBox padding keeps stroke and dot from clipping at the edges. The line draws itself on mount the way the recharts lines do — the path normalises to 100 dash units via `pathLength` and the offset retreats over `--motion-duration-slower` `--motion-ease-emphasized`, with the area fill fading behind it and the dot waiting out the draw — all token durations, so reduced motion collapses the sequence. Degenerate data never produces a NaN path: an all-equal series renders a horizontal midline, a single point renders just the dot, an empty array renders nothing. Decorative by default (`aria-hidden`); passing `label` switches to `role="img"` with `aria-label`.

### Gauge

**`ds-gauge`** — A radial dial for a single bounded reading: capacity, usage, a score against a target. Like Sparkline, dependency-free SVG with no `'use client'`, so it renders from a Server Component and keeps its own folder rather than joining `Chart/`. It wears the chart family's shared chrome — the `ds-chart` card, header (`title`/`subtitle`), and padding come from `Chart.css`, with `bare` stripping them inside a Panel; unlike the fluid recharts bodies the dial has intrinsic size, so a bare gauge shrink-wraps for Stat rows and tiles. The dial is a three-quarter circle (270° sweep, gap at the bottom) drawn as two `pathLength=100` circles: a quiet `--color-bg-container-tertiary` track and a value arc in `currentColor` (default `strokeWidth` 12, round caps like the family's ring ends), animating between readings via a `stroke-dasharray` transition at `--motion-duration-slow` `--motion-ease-emphasized` and sweeping in from an empty dial on mount (`--motion-duration-slower` `--motion-ease-emphasized`, a single keyframe that animates to the element's own dasharray — token-composed, so reduced motion collapses it). Colour flows through `color` per tone — `accent` (default) `--color-chart-series-1`, the family's lead data colour, then the `positive`/`warning`/`error` status **border** tokens (stable across themes, where the dark-mode text tints are near-white and would wash the tones into one) and `neutral` `--color-text-secondary` — and `thresholds` recolour the dial as the reading crosses them (highest threshold at or below the value wins), which is how a capacity dial goes amber at 70 and red at 90. The centre mirrors the family's radial centre label: the reading in `--font-sub-display-*` `--color-text-primary` (formatted via `formatValue`) over an optional caption (`--font-caption-*`, `--color-text-secondary`) that doubles as the accessible name. `size` and `strokeWidth` are SVG geometry in pixels, not tokens. The dial is announced as `role="meter"` with `aria-valuenow/min/max` and the formatted reading as `aria-valuetext`; a degenerate range (`max <= min`) draws an empty dial rather than NaN.

### FunnelChart

**`ds-funnel-chart`** — Ordered stages as a centred funnel of trapezoid bands, built on Recharts' native `Funnel` (unlike Sparkline and Gauge it joins the recharts side of the family, so it ships from the `charts` barrel and needs the optional recharts peer), which is what gives it the family's sweep-in mount animation for free — guarded against reduced motion by Recharts' own `'auto'` animation default, like the rest of the recharts set. Each band's width is its stage's share of the first, the honest recharts geometry: the taper itself is the conversion story. It wears the chart family's shared chrome — the `ds-chart` card, header (`title`/`subtitle`), and padding come from `Chart.css`, with `bare` stripping them inside a Panel. Stage colours cycle `--color-chart-series-1` → `-7`, resolved live in SVG paint; hovering a band raises the family's glass tooltip with the stage name, its share of the first stage, and its reading (`displayValue`, falling back to the locale-formatted value). Stage names label each band on the right in `--color-text-secondary` (SVG text pinned to `--font-family-primary`); `showLabels={false}` drops them when a LegendTile row under the chart already carries the names. `minStageShare`, the stepped-bar rendering's height floor, is deprecated and ignored. The chart body is `role="img"` with a generated label naming every stage and value.

### GanttChart

**`ds-gantt`** — Phases and tasks as bars on a shared timeline, with milestone diamonds, optional per-bar progress, month gridlines, and a today rule. Read-only by design: it draws a schedule, it does not edit one. Like Sparkline and Gauge it is dependency-free — computed markup with no `'use client'` and no hooks, so it renders from a Server Component, keeps its own folder, and stays out of the recharts barrel. It wears the chart family's shared chrome — the `ds-chart` card, header (`title`/`subtitle`), and padding come from `Chart.css`, with `bare` stripping them inside a Panel. Dates are ISO `YYYY-MM-DD` strings parsed local; the window fits the data (or an explicit `range`) snapped outward to whole months, and bar geometry is percentage positions over that window — computed coordinates set inline, like the family's other drawn charts. Bars are `--radius-pill` pills (the ProgressBar/Meter species): per-item accent flows through `--ds-gantt-accent`, set by a modifier class from the core accent roles — EventCalendar's recipe, with `--color-status-neutral-border` as the unaccented default. A `progress` reading turns the bar into a quiet `--color-bg-container-tertiary` track with the accent filling the completed share (transitioning at `--motion-duration-slow` `--motion-ease-emphasized`, token-composed so reduced motion collapses it); `projected` draws a dashed accent outline instead — a target, not a commitment. Milestones are 12px rotated squares at `--radius-050` on their own rows. The today rule is `--color-bg-page-inverse` with an inverse chip flag, EventCalendar's today convention — today is current, not selected, so teal stays reserved for the selected bar's outline (`selectedId`, paired with `onItemClick`); `today` pins the rule to a given day instead of the render-time clock, so a statically built page and its hydrating client can never disagree about where it sits (the roadmap planner template is the worked example). The sticky `--color-bg-container-primary` label rail pins left while the timeline scrolls in its own container on narrow screens; group headings are overline rows. The structural sizes are component-scoped custom properties a host may retune — `--ds-gantt-label-width` (172px), `--ds-gantt-track-min` (480px), `--ds-gantt-row-height` (36px), `--ds-gantt-bar-height` (20px) — the CardStack recipe, and how a narrow column (the landing's one-third card) fits the chart without sideways scroll. Honest semantics rather than a claimed grid: each group is a labelled list, the drawing layer is `aria-hidden`, every bar carries its full reading ("Budget, Mar 9, 2026 to Mar 27, 2026, 65% complete") as tooltip and accessible text, and bars render as buttons only when a click callback exists. Degenerate input — no rows, unparseable or inverted dates — draws an empty body rather than NaN positions.

### LegendTile

**`ds-legend-tile`** — The labelled value tile that sits under a chart and ties a series to its number: an optional 8px series dot (any CSS colour via `swatch`, typically a `--color-chart-series-*` token), the series name in `--font-caption-*` secondary with ellipsis, and the reading in `--font-paragraph-sm-emphasis-*` primary. Fill is `--color-bg-page-primary` at `--radius-300` — one step below a panel's `container-primary`, so the tile reads as an inset rather than a raised card. Presentational and server-renderable; numbers format with `toLocaleString()`.

### Globe

**`ds-globe`** — An orthographic globe drawn in SVG for the geometry of a network — where things are, what connects them — deliberately without a map library, tile server, or land data: the graticule alone gives the sphere its shape. The drawing lives in a 480-unit viewBox (sphere radius 180, leaving air for lifted arcs) and scales to its container, which keeps `aspect-ratio: 1`; stroke widths are viewBox geometry like Sparkline's, not tokens. Colour is all tokens: the limb in `--color-text-tertiary` at reduced opacity, the graticule in `--color-divider` (back hemisphere runs dashed and fainter), markers in the text ramp (`--color-text-secondary`, primary when active) unless a point carries its own `color` — the same per-point channel WorldMap has, so one dataset keys identically on both projections; a coloured marker keeps its colour on hover instead of brightening — labels in the code face at `--font-caption-size`. Arcs are great circles sampled and lifted by `altitude × sin(πt)`, split into front and back runs (back at 0.15 opacity); the default stroke is a per-arc `userSpaceOnUse` gradient from `--color-chart-series-7` to `--color-chart-series-5` — the cobalt-to-violet end of the chart series — and a per-arc `color` replaces it with a flat stroke. Markers come in two kinds, `point` (a cross) and `anchor` (a square), each with an oversized transparent hit circle; the active or hovered point scales its glyph up over `--motion-duration-fast` and can render an annotation through `renderCallout`, placed in an HTML overlay beside the marker that flips sides at the disc's centre (`data-side`), with MapCallout the intended filling. Rotation follows the controlled/uncontrolled pair convention (`rotation`/`defaultRotation`/`onRotationChange`, `[lng, lat]` degrees, latitude clamped at ±80° so the poles never flip); drag rotates a quarter turn per radius, arrow keys or W/A/S/D step 10° when focused, and `autoRotate` (degrees per second) spins slowly, pausing on hover, drag, or focus and never running under `prefers-reduced-motion` (a JS-side check, same recipe as Carousel). Interactive, the root is a focusable `role="application"` with `aria-roledescription="globe"`; non-interactive it is `role="img"`. The accessible name appends the point and arc counts and the interaction hint; the SVG itself is `aria-hidden`.

### MapCallout

**`ds-map-callout`** — The annotation beside a point on a map or globe: the place's name in capitals (the `--font-overline-*` face on the code family) over readout lines in the code face (`--font-family-code`, `--font-caption-*`, `tabular-nums`) in `--color-text-tertiary`, so a coordinate and a reading line up digit for digit. `align="start"|"end"` sets which way the block ranges — a callout left of its marker ranges `end` so it hangs off the point, and Globe's overlay `data-side` says which to use. Purely presentational — no `'use client'`, renders from a Server Component. The `title` prop deliberately shadows the native tooltip attribute.

### MapLegend

**`ds-map-legend`** — The block in the corner of a map: its name in `--font-title-body-*`, a line on what it shows in the code face at `--font-caption-*` secondary, and the key to its markers. The key is a `<dl>` — glyph as `<dt>` (aria-hidden; the label carries the meaning), meaning as `<dd>` — with rows at `--gap-200`. Built-in glyphs `point`, `anchor`, `arc`, and `line` are drawn on the same geometry Globe draws its markers with (cross, square, and the same cobalt-to-violet series gradient for the arc), so a globe's key never shows a shape its map does not; WorldMap's filled dot and outlined square are its own, so a flat-map key passes its glyphs as React nodes — `line` takes a flat `color`, `arc`'s `color` replaces its gradient, and any node renders as given for custom swatches. Capped at `32ch` so it stays a corner block. Purely presentational — no `'use client'`; the `title` prop deliberately shadows the native tooltip attribute.

### WorldMap

**`ds-world-map`** — The flat companion to Globe, for when the geography should read at a glance rather than turn: a Web Mercator map drawn in SVG from Natural Earth's 50m land shapes, baked into the package as one path by `scripts/sync-worldmap-land.mjs` (a deliberate, run-once network fetch — the build never touches the network, and the script owns the projection constants the data was baked with). Still no map library and no tile server: the land is data, so the whole map themes with the tokens — fill `--color-bg-container-tertiary` with a `--color-divider` coastline, the graticule in `--color-divider` at half opacity, both on `vector-effect: non-scaling-stroke` so hairlines stay hairlines at any framing. The root fills its container — size a wrapper, never the root through `className`, whose height would tie with the component's own fill rule at equal specificity and lose in some stylesheet orders (the 2026-09-17 production lesson: the showcase maps rendered at 0px height while dev looked fine). The drawing space is a 0..360 square (one unit per degree of longitude, latitude clamped at ±85.05°), so the `bounds` prop — `[west, south, east, north]`, never crossing the antimeridian — becomes the viewBox directly; `fit="cover"` fills a stage edge to edge the way `contain` letterboxes, implemented by fitting an aspect-true frame to the measured container (ResizeObserver) so marker percentages stay exact. Markers are HTML buttons over the drawing, not SVG: hover, focus, and click all work, each carries its point's `label` as its accessible name, and each can carry its own `color` (any CSS colour, typically a `--color-chart-series-*` token) through a custom property — the flat map's channel for colour-coding a category, where Globe puts colour on its arcs. The 20px hit target around a 10px glyph is drawing geometry, not spacing, like ToggleSwitch's thumb; `point` is a filled dot ringed in `--color-bg-container-primary` (a contrast ring, not an elevation shadow), `anchor` the family's outlined square at `--radius-050`. The active or hovered marker scales its glyph over `--motion-duration-fast` and can render an annotation through `renderCallout` — the same overlay contract as Globe (`data-side`, MapCallout the intended filling), opening toward the map's centre where the room is. `renderHoverCard` is the floating-panel alternative: the map composes the library's HoverCard around each marker (a position slot owns the placement so HoverCard keeps its own layout contract, and the marker colour inherits down), opening on hover or focus with the system's delays and Escape dismissal, downward for a marker near the frame's top, with the hovered or focused slot rising above its neighbours; content must stay phrasing-level, the panel's own rule, and when both renderers are given the hover card wins. `showZoomControls` adds a navigable view inside `bounds`, with the website's diagram-pill controls brought into the component: a glass pill floating at the bottom centre (`--color-bg-container-primary-semi` over a 24px backdrop blur, hairline `--color-divider` border, `--shadow-floating`) holding zoom out, a `tabular-nums` percent readout that resets to 100% on click, and zoom in — composed compact **tertiary** CircularButtons, the architecture maps' recipe. Steps are 1.5× up to `maxZoom` and down past 100% to a computed floor — the whole world in the window with air around it — the readout resets to the given frame, ctrl/⌘ + wheel (a trackpad pinch) zooms at the pointer while a plain scroll keeps scrolling the page (a native non-passive listener, since `preventDefault` is load-bearing), and the frame pans by drag and by arrow keys with the root focused (`+`/`-` zooming) — Globe's drag-and-keys contract translated from rotation to pan. Panning roams the whole world at any zoom, clamped to the world's extent: `bounds` is the view a region starts and resets at, not a wall. Mouse and pen grab whenever there is world beyond the window; a finger only drags while zoomed in (`touch-action` locks only then), so an unzoomed map never swallows a phone's page scroll — the architecture maps' embedded contract. The graticule covers the window rather than the world for the same reason: meridians continue past the antimeridian and parallels past ±80° at the step's own spacing, so panning or zooming out never runs off the lined paper — the map just gains space around it. The view is keyed to `bounds`, so a region change resets to zoom 1; a drag that started on a marker is never a click (a small movement threshold decides). Interactive, the root is a labelled `role="group"` of buttons (focusable itself only when the zoom keys are live); `interactive={false}` renders inert glyphs under `role="img"`. The accessible name appends the drawn point count and, with zoom on, the navigation hint; the SVG itself is `aria-hidden`.

### AgentPlan

**`ds-agent-plan`** — The steps an agent intends to take and where it has got to. It completes the agent vocabulary: AgentStatus says what is happening right now, ToolCall records one action, AgentPlan holds the whole task. Same container recipe as ToolCall — `--color-bg-container-primary` at the chat furniture's shared `--radius-600` shell with a hairline `--color-bg-container-border`, content inset `--padding-500` from the sides — and the same `0fr → 1fr` grid collapse for the panel, opened by default. The header button carries a computed title ("3 steps left", or "All steps complete"; `title` overrides), a `tabular-nums` completed/total readout in `--color-text-tertiary`, and the rotating `expand_more` chevron.

Steps render as an `<ol>` with one row per step: an indicator in a fixed `--icon-size-500` box (the active step's sm Spinner is smaller than the step icons, and the fixed box keeps every state centred on the same axis), the label, and an optional `detail` line in `--color-text-tertiary`. Four statuses, coloured through the plain-surface `--color-status-*-icon` steps — `pending` (`circle`, neutral), `active` (Spinner at `variant="inherit"` in info, label bumped to `--font-paragraph-sm-emphasis-*`, `aria-current="step"`), `completed` (`check_circle` in positive, label receding to `--color-text-tertiary`), `failed` (`error` in error, label in `--color-status-error-text`). The container stays neutral like ToolCall — colour lives in the indicators, and a thin `--color-divider` connector ties them into one path through the task. Status is colour + glyph for sighted users; a visually hidden span announces it per step.

### AgentRail

**`ds-agent-rail`** — The companion rail of an agent product, and the mirror of ThreadPanel: where the left rail holds the sessions a person has had, this one holds the agent itself — who it is at the top, and the record of what it has been doing below, split across tabs. Deliberately **surface-less** like its sibling, so the host paints the band it sits on, and it scrolls exactly one region — the active pane — behind the same tucked-away thin scrollbar (`scrollbar-width: thin`, no track fill, the `--color-divider` thumb showing only under the pointer). The rail owns its width: 360px, set by the widest thing in it, which is the tab strip rather than the rows — four labelled tabs measure a little over 310px, and a rail that clips its own navigation is not a rail. The sibling history gets away with 280 because a thread row is one line; here a row carries a title, a second line and a time as well. A host that wants another width overrides it.

The header is a centred identity block, not a row: the portrait — an `lg` Avatar when `avatarSrc` is given, the rail's own `smart_toy` mark on Avatar's exact footprint and fill when it is not (initials would claim a person, which is the one thing the subject here is not), or any element through `avatar` for a product whose agent has a face of its own — over the name in `--font-title-body-*` and an optional status line in `--font-caption-*` on `--color-text-secondary`, led by ThreadPanel's `--gap-200` dot (the gap scale, because no icon token is that small) in any of the five `--color-status-*-icon` roles. `onCollapse` — for a host with no toggle of its own; the site chat leaves it unwired, since its header button is the way both in and out — floats a `chevron_right` over the rail's top **leading** corner, wearing a tertiary CircularButton's exact footprint and resting treatment (40px, `--icon-size-600`, `--color-action-passive-bg` under `--color-icon-primary`), because that is what it ends up in a row with rather than seating it in the header, for two reasons: the header is a centred identity block, so a control in that column would push the portrait off the rail's axis, and the trailing corner belongs to the host — a rail seated at a card's edge is where the card's own controls end up, and the collapse would sit under them. A chevron and never a cross: the host's own close control is usually a cross a few pixels away, and two of them side by side read as two ways to dismiss the same thing. The way back in belongs to the host. `onEdit` hangs a 24px circular `edit` affordance on the portrait's lower-right corner — the seat an Avatar's own status dot would take, so the two are alternates rather than a stack — filled `--color-bg-container-primary` inside a hairline `--color-bg-container-border` so the circle reads as lifted off the face beneath it, washing to `--color-action-passive-bg-hover`. The pencil renders only when the callback is wired. With no profile at all there is no header for the chevron to float over, so the rail reserves the control's own row above the strip rather than letting it land on the first tab.

Tabs are a content-sized `compact` `neutral` SegmentedControl in `collapse` mode, centred in the rail, which brings the `role="tablist"` semantics, the sliding pill, arrow-key traversal and the responsive ladder with it. The ladder is what makes four tabs fit a rail at all: icon and label together need 423px, labels alone 311, icons alone a little over 200, and the control measures all three from its own first render and takes the widest that fits. So the shipped 360px rail shows labels, a host with room shows both, and a squeezed one — the bottom sheet on a small phone, a narrower host override — falls to glyphs with the words still in the accessibility tree. Decoration goes before meaning, which is why the icon is shed first and the label last. The pane below is a `role="tabpanel"` named by the active tab's label and carrying `tabIndex={0}`, since it is the rail's only scroller and may hold nothing focusable at all; the header and the strip stay put while it changes, so switching tabs never moves the agent's face. Panes are host content — the four the pattern is named for (activity, approvals, automations, personalization) compose NotificationItem rows and EmptyState rather than shapes of this component's own, which is what keeps an activity row and a notification the same row.

Fully controlled and hook-free: the host owns which tab is on stage, and the component carries no `'use client'`, so a rail whose panes are static renders from a Server Component. `footer` seats host furniture below the scroll, above a `--color-divider` rule.

### AgentStatus

**`ds-agent-status`** — What an agent is doing right now: a dot-matrix indicator beside a line of status text. The matrix is a 4×3 grid of 12 `--radius-pill` dots sized off the icon scale (`--icon-size-600` wide at the default size, `--icon-size-500` at `compact`) with `--gap-050` between them, so the dots derive their size from the grid rather than carrying pixel values of their own. The label is set in `--font-title-body-*` — a status is a heading for the work underneath it, not prose. `compact` keeps the `--font-paragraph-sm-*` metrics (the scale has no 14px title face) but carries the same `--font-title-body-weight`, so one size of the component can't read as a heading and the other as prose; the size still pairs with ChatMessage's default and compact message text (`sm`/`md` remain as legacy aliases). The default size costs nothing in layout: the title-body and paragraph sets share 16px/24px metrics, so only weight and tracking change.

The named patterns — `AgentStatusPatterns.tsx` is their authoritative list (`braille`, `orbit`, `breathe`, `snake`, and the rest) — are choreography over that one grid. Every pattern runs on the same twelve-slot cycle (`--motion-duration-loop-matrix`, 1400ms): each dot is told which slot it lights in via `--ds-agent-step`, and dots a pattern never lights opt out of the animation. One grid and one cycle length means changing pattern never changes the indicator's footprint or its rhythm.

Six states: `idle`, `thinking`, `working`, `waiting`, `done`, `error`. The three working states are deliberately monochrome (`--color-text-secondary`, `--color-text-tertiary` when idle) — colour is reserved for the states where it carries meaning, taking `--color-status-warning-text`, `--color-status-positive-text` and `--color-status-error-text` respectively. The terminal states fill the matrix solid so the shape itself reads as finished. While active, a sweep walks the label left to right (`--motion-duration-loop-shimmer` over a `background-clip: text` gradient from `--ds-agent-shimmer-base`, default `--color-text-tertiary`, through `--ds-agent-shimmer-sweep`, default `--color-text-primary`; `waiting` keeps its warning base and sweeps `--color-text-primary`); `shimmer` overrides the default. The base is the dimmer colour and the sweep the brighter one, so what travels is a highlight rather than a shadow — the reading `waiting` always had, now the rule. The band is narrow, 12% of a 250%-wide image and so under a third of the label at a time: spread wide it washes the whole label at once and stops registering as movement at all. When the `label` string changes, the outgoing text fades up and out while the incoming fades up and in over `--motion-duration-slow` — a status that narrates progress glides between steps rather than snapping. The shimmer's text clip stands down for the swap (an animated descendant composites on its own layer, which escapes `background-clip: text`) and resumes on the settled text; `children` are consumer-owned markup and render without the transition. Every colour on the component routes through custom properties — `--ds-agent-color` for the dots, `--ds-agent-label-color` for the text, plus the shimmer pair — so a consumer can tint a row (e.g. one `--color-core-accent-*` per agent when several share a surface; the showcase page demos one variation per core accent).

`variant="bar"` wraps the row in a full-width `--color-bg-container-secondary` container at `--radius-300` for the top of a panel. The root is a `role="status"` live region, and the matrix is `aria-hidden`, so the state change is announced once as text rather than as twelve dots. Under `prefers-reduced-motion` the matrix parks at a legible static opacity and the shimmer resolves to flat text — the global guard would otherwise collapse every dot to its dark final frame.

### AiButton

**`ds-ai-button`** — The AI entry point: icon and label on a solid `--color-ai-button-bg` field — the theme's extreme surface, bright white in light and true black in dark, so the pill stands off any page floor — ringed by the AI gradient and haloed by a soft glow of the same gradient escaping behind the opaque fill. The ring is the system's signal for "a model answers here": ordinary actions keep the flat `--color-action-primary-bg` teal, and this treatment is reserved for AI surfaces so neither affordance dilutes the other. It is a pill (`--radius-pill`, the button invariant) on Button's control scale — same padding tokens, same `--font-paragraph-emphasis-*` face, `default` and `compact` sizes, icon on `--icon-size-600`/`sm`.

The three stops are the AI gradient tokens (see **AI** in the Colors chapter for the role and its per-theme values). The ring paints a conic gradient of the three stops (start → mid → end → back to start, so the seam at 360° is invisible) across a `::before`, masked to a `--border-050` band with an exclude-composited two-fill mask — a heavier stroke than the system's hairline borders, because at 1px the gradient loses its colour on low-density screens. Rotation animates a registered `@property` angle on a deliberately off-scale 21s loop of three uneven turns — keyframes at uneven angles, eased per segment with `--motion-ease-emphasized`, so the ring drifts, surges and settles rather than ticking round at one rate, ending on a whole turn so the wrap is seamless and the average pace stays one turn per 7s (sanctioned in place with `ds-allow(motion)`: ambient signature motion, far slower than the interaction tokens); browsers without `@property` get a static ring. The glow is a `::after` of the same gradient blurred (a component-local `--ds-ai-button-glow` length, chosen by eye) at 0.3 opacity, rising to 0.55 on hover — a colour effect, not an elevation shadow, so depth stays token-owned. On mount the pair plays a one-shot entry: the glow blooms out of the bottom-right corner (scale from a corner `transform-origin`, opacity from 0, `--motion-duration-slower` on `--motion-ease-entrance`) and the ring fades up into place a `--motion-duration-fast` beat behind it, so the button lights rather than arriving at full strength — from-only keyframes settling onto the base styles, composed alongside the turn as a second animation; a disabled button pauses only the turn, letting the entry complete. Focus is the standard `--color-action-primary-bg` ring; disabled drops to 0.4 opacity and pauses the rotation; `prefers-reduced-motion` stops the turning and keeps the ring and glow. Renders `<button>` or, with `href`, `<a>`.

With `summary`, the button summons a TLDR panel (`ds-ai-button__summary`): a frosted glass card (`--color-bg-glass` thinned in light via a sanctioned `color-mix`, heavy blur sanctioned in place) at `--radius-600` with the ordinary `--border-025` container border, opening above or below per `summaryPlacement` and right-aligned to the button. Button and panel live in one hover host (`ds-ai-button-host`), so pointer travel between them never closes the panel: it opens after `MOTION_HOVER_SHOW_DELAY_MS`, hides `MOTION_HOVER_EXIT_GRACE_MS` after the pointer leaves the whole host, and `summaryPinned` holds it open for callers with their own reveal signal. On open the AI gradient sweeps once around the panel's border (the button's masked conic ring run as a single 0→360° pass, then fading out — a registered `@property` angle, one-shot), the button gives one `--motion-ease-spring` acknowledge pulse, and the content rows stagger up on `--motion-ease-entrance`. The panel fakes its generation once per mount: Skeleton lines for `MOTION_SUMMARY_THINK_MS`, then the pre-written text through StreamingText within `MOTION_SUMMARY_REVEAL_MS`; later openings show the text instantly, as if cached. The overline sits in the `--font-overline-*` face, the title in `--font-title-body-*`, the text in `--font-paragraph-*`, and the chips are PromptSuggestions in its stacked compact form. The chips wait for the reveal to finish — the same "here is something you could ask" beat the chat's follow-ups take; showing them mid-think would give the staging away. During the type-out an invisible ghost of the finished text sizes the container, so the stream never jumps the panel and a short summary reserves no space it won't use. The panel stays mounted and hides on `visibility` — HoverCard's approach — so closing eases back down the path the entrance sprang up while the hidden panel stays out of the accessibility tree and the tab order. A corner dismiss button closes it for the visit and fires `onSummaryDismiss`; leaving the host re-arms it, so the next hover summons it fresh. The card caps at a component-local 340px measure (minus a phone margin) on `--shadow-floating`. `prefers-reduced-motion` drops every panel animation; the think beat still stages, being a schedule timing, while StreamingText's own check makes the reveal instant.

### ChatHeader

**`ds-chat-header`** — The top row of a chat surface: the conversation's name on the left, its controls on the right. A space-between flex row padded `--padding-150` `--padding-400` at `--gap-400`, deliberately drawing no bottom border — like Composer at the other end, it floats free of the transcript, and the thread's own top padding provides the separation. A string `title` renders in the `--font-title-body-*` set in `--color-text-primary`, truncating with an ellipsis so a long conversation name never wraps the row; a node `title` renders as-is for consumers who need their own heading element. The `title` prop deliberately shadows the native tooltip attribute — a header's title is content.

`actions` is a trailing flex row at `--gap-100` that only lays controls out: the intended fillings are tertiary CircularButtons (new chat, close) and a view-switch toggle, each owning its own behaviour and accessible name. The component is purely presentational — no `'use client'` — so it renders from a Server Component.

### ChatMarker

**`ds-chat-marker`** — The inline separator a conversation uses for anything that is not a turn: date breaks, joins, mode changes, system notes. A `--font-paragraph-sm-*` label in `--color-text-tertiary` sits between two flanking `--border-025` lines in `--color-divider` (`--gap-300` off the text), so the row reads as furniture rather than as a message. An optional leading Material Symbol renders at `--icon-size-500` and is `aria-hidden` — the label carries the meaning. `line={false}` keeps the flanking segments as transparent spacers, so a bare note stays centred on the same geometry.

The root is `role="separator"` with the label as its content: screen readers treat it as a boundary, not a message, matching how the eye skips it while scanning turns. System *events* belong here; system *messages* with content are ChatMessage's job.

### ChatMessage

**`ds-chat-message`** — A single chat turn: avatar, author, timestamp, and the content itself, aligned by role. User turns are right-aligned bubbles; assistant turns are surface-less full-width text in `--color-text-primary`, so a transcript keeps its question-and-answer rhythm without every row wearing a surface. The `bubble` prop overrides the role default in either direction: an assistant turn can take a received bubble for classic messenger layouts, a user turn can go plain. Bubble surfaces come *only* from the four chat bubble tokens — sent bubbles pair `--color-chat-bubble-sent-bg` with `--color-chat-bubble-sent-text`, received bubbles the matching received pair — never the container ramp directly, so re-theming a chat means repointing those four tokens and nothing else. The action colour never appears as a bubble fill: a teal bubble would spend the "click here" signal on something that is not clickable.

Bubbles sit at `--radius-600` with `--padding-300` `--padding-500` padding and cap at `--ds-chat-message-max-width` (default 75%), a consumer-override hook; `tail` squares the speaker-side bottom corner to `--radius-100` — bottom-right on sent, bottom-left on received — a corner treatment only, with no pseudo-element pointer. Content sits on the `--font-paragraph-*` set; `size="compact"` drops it to `--font-paragraph-sm-*` and tightens bubble padding. Content is width-locked to its container — `min-width: 0`, `max-width: 100%` and `overflow-wrap: anywhere` on the content block, so an unbroken run (a URL, a token) wraps inside the bubble or column instead of pushing the surface wider. The meta row is the author in `--font-paragraph-sm-emphasis-*` `--color-text-secondary` beside a free-text tabular-nums timestamp in `--color-text-tertiary`. The avatar gutter is a fixed `--ds-chat-message-gutter` (default `--icon-size-800`, matching Avatar's small size): `showAvatar={false}` and `grouped` hide the avatar but keep the gutter, so consecutive rows in a run stay aligned; `grouped` also drops the meta row and pulls the row up towards the one above. Card furniture inside a turn (MessageCard, InterruptCard, ToolCall as direct content children) takes `--gap-300` of clearance from the content before and after it — margins, not gap, so a card standing alone in a turn adds nothing.

`pending` replaces the content with three `--color-text-tertiary` dots pulsing in a staggered wave on `--motion-duration-loop-matrix`, wrapped in `role="status"` with a visually hidden `pendingLabel` and the dots `aria-hidden`; under `prefers-reduced-motion` the dots park at a legible static opacity rather than freezing at the loop's dim frame. `actions` renders under the content, revealed on hover and `:focus-within` over `--motion-duration-fast` — and always visible where hover does not exist (`hover: none`), so touch users are never locked out; `showActions` pins the row on permanently, for surfaces where the actions are part of the response (the site chat's copy and thumbs row) rather than an affordance to discover. `footer` is an always-visible slot for sources or an edited note. The package ships no markdown renderer: render markdown yourself, ideally wrapped in Prose, and pass the result as children.

### ChatThread

**`ds-chat-thread`** — The scrollable conversation column, and the home of the chat surface's most important interaction: the send choreography. The root is the scroll container; turns stack inside an inner `__content` wrapper (flex column at `--gap-1000`, padded `--padding-500` with the inline gutters routed through `--ds-chat-thread-padding-inline` as a consumer-override hook for mobile densities), and every entry is `flex-shrink: 0` so children that clip their own overflow never collapse. A second hook, `--ds-chat-thread-content-max-width`, caps the conversation column and centres it — for wide surfaces where the container grows but the column should not — while the scroll container, its fades, and the scrollbar keep spanning the full component width. Both ends fade through a pure-CSS `mask-image` gradient with `--gap-500` stops — the PromptSuggestions treatment turned vertical — so content scrolling out passes through the fade with no scroll listeners involved (the mask alpha keywords carry a `ds-allow(color)` sanction).

On append, a newly added turn *floats to the top*: the component sizes a trailing spacer so the first new child can sit exactly at the top of the viewport — even when the newest exchange is short — then scrolls to it, pushing the prior conversation upward. The glide comes from `scroll-behavior: smooth`, which the reduced-motion guard in the motion tokens forces back to instant; because content streaming in *below* the viewport never moves the scroll position, the anchored turn stays put while a response grows beneath it. When a user turn and the agent's pending turn are appended in one update, the user turn is the anchor. The first send into an empty thread has no scroll distance yet, so it gets the same feel from a transform instead: the new turn starts at composer level and floats up to the anchor position over `--motion-duration-slower` `--motion-ease-entrance` (a thread that mounts with a restored transcript instead opens instantly at the latest message, re-establishing the anchor). The spacer holds exactly the shortfall between the anchor and what the content can already reach, tracked in both directions for the whole exchange, so the scroll range ends precisely at the anchor and never past it. That is what keeps the dead space honest: reserved room a growing answer has since filled is given back as it goes, so the space is gone by the time the answer lands rather than collapsing after it — no jump, because the range it defines never moves. Growing it back is the direction that needs the clamp guard: content shrinking mid-exchange pulls the range under the anchor a frame before a ResizeObserver can react, so the position is restored alongside the range. A shrinking child count reads as a conversation reset: the spacer collapses and the thread returns to the top. `anchor={false}` opts out entirely.

The scrollbar is deliberately subtle: `scrollbar-width: thin` with `scrollbar-color` held transparent until a scroll event lands, then `--color-divider` over a transparent track while scrolling continues, fading back out after a settle delay (`MOTION_SCROLL_SETTLE_MS`, from the shared JS motion constants in `src/tokens/motion.ts`); `scrollbar-gutter: stable` reserves the gutter so a scrollbar popping in never reflows content sideways, and the component publishes the measured gutter width as `--ds-chat-thread-gutter`, which the content's right padding subtracts (clamped at zero) — so on classic-scrollbar platforms the column stays perfectly symmetric and flush with its surroundings, with the scrollbar tucked outside it at the far edge. Because the inline gutters live on the inner wrapper, the scrollbar rides the component's far edge rather than sitting inside the content column. The root is a `role="region"` named by `ariaLabel` (default "Conversation") with `tabIndex={0}`, so the scrollable area is keyboard-reachable and takes the house focus ring inset.

A scroll-to-bottom control appears whenever there is anything left to scroll to: a `--radius-pill` circle matching Composer's send button — the same 40px footprint (`--icon-size-600` glyph plus the `--padding-150` ring), pointing the other way — on the action-passive ramp painted over a `--color-bg-container-primary` surface with a `--border-025` `--color-bg-container-border` edge and `--shadow-floating`, centred in a sticky zero-height slot pinned just above the bottom fade, floating over the transcript right on top of the composer. Clicking it glides to the bottom through the same CSS `scroll-behavior` the anchoring uses, and once nothing is left to scroll to it fades out and leaves the accessibility tree (`aria-hidden` plus `tabIndex={-1}`); `jumpLabel` names it for screen readers. The content column's bottom padding is `--padding-1000`, so the end of a long response rests 40px clear of the composer.

### Composer

**`ds-composer`** — The chat input shell: an optional context note, an attachments row, an auto-growing textarea, a leading actions slot, and a trailing send button. The shell is the control — a `--color-input-bg-primary` surface behind a `--border-025` `--color-input-border-primary` border, floating on `--shadow-floating`. It stacks two zones with different densities: a text zone padded `--padding-400` `--padding-500` `--padding-200` (roomy above and beside the words, tighter toward the bar), and an action bar padded a uniform `--padding-200` holding the leading `actions` slot, the `trailingActions` slot, and the send button. Bar icon actions take *tertiary* CircularButtons at the default size — borderless ghosts that only show a fill on hover — so every control in the bar shares the send button's exact 40px footprint and `--icon-size-600` glyph, and the send button stays the only drawn circle. The radius is the one deliberate departure from the inputs-are-`--radius-300` rule: the corner is the component-local `--ds-composer-radius`, derived live as the send button's effective radius (its `--radius-pill` capped at half its size) plus the bar's `--padding-200` ring plus the `--border-025` width — 29px in the shipped theme, and automatically concentric under any re-themed radius (a crisp preset that squares the pill squares this corner with it); it is one component's geometry, deliberately not a step on the global radius scale. The textarea inside is borderless and outline-free so the shell carries the whole interaction ramp: `--color-input-border-hover` on hover, `--color-input-border-selected` on `:focus-within`, transitioning over `--motion-duration-fast` `--motion-ease-standard`. The shell is also the click target: clicking anywhere on it that is not a control focuses the textarea, with a `cursor: text` affordance everywhere except the action bar, which keeps the default cursor. Text sits on the full `--font-paragraph-*` set in `--color-input-text-primary` with `--color-input-text-placeholder` placeholders. The send button is a composed primary CircularButton (`arrow_upward`) — the *one* sanctioned action-colour teal in the chat set, because sending a message is a genuine primary CTA, exactly what that token is reserved for. It disables while the trimmed value is empty; while `streaming` it swaps to a stop button (`stop`, firing `onStop`) and both Enter and `onSubmit` go inert, so a person can never fire a submit into a running response. Enter without Shift submits; Shift+Enter breaks the line. The trimmed-empty guard means whitespace never sends. Native `disabled` takes the house 0.4 opacity with `--color-input-bg-disabled` and not-allowed cursors, and disables the send button with it.

The textarea starts at one row and grows with its content up to `maxRows` (default 8), then scrolls internally: the cap is `calc(var(--ds-composer-max-rows, 8) * var(--font-paragraph-line-height))` — the shell owns the block padding, so rows × line-height is the whole sum — with `--ds-composer-max-rows` set inline from the prop. Where the browser supports `field-sizing: content` the sizing is fully native; elsewhere a measurement effect sets the height from `scrollHeight` on every value change. The step between one height and the next is eased rather than snapped: a ResizeObserver publishes the textarea's measured height to the text zone as `--ds-composer-text-height`, and the zone states its height as that plus its own block padding, so growing a line and collapsing on send both run between two pixel values (`auto` has nothing to interpolate) over `--motion-duration-instant` `--motion-ease-emphasized`. At 75ms it lands under the threshold where a change reads as motion — the edge comes off the jump and nothing more. The zone clips (`overflow: hidden`) while the textarea sits at its new height already, so a new line is uncovered rather than pushed, and the action bar's buttons hold their geometry throughout — the bar translates with the shell, never resizing. `attachments` is a wrapping row above the textarea for DocumentChips, fully controlled by the caller — Composer never owns the file list, mirroring FileInput's philosophy — and `actions` is the leading footer slot for an attach button or model picker. `context` renders above both, at the very top of the shell: a full-width, non-interactive chip — filled with `--color-chat-context-bg`, the quietest tint per theme (the nearest neutral to each shell that still separates from it: `--primitive-neutral-01` light, `--primitive-neutral-09` dark) at `--radius-pill`, on ModelPicker's trigger geometry (the send button's 40px height, `0 var(--padding-400)` inset) so the chip and the bar's pills read as one family, inset by the action bar's `--padding-200` ring, `--font-paragraph-sm-*` in `--color-text-tertiary` (DocumentChip's metadata tone — the note is ambient context, quieter than anything a person typed) — the "what the model is looking at" note a chat host pins over the message ("Looking at “Page name”"). `contextIcon` names an optional Material Symbol rendered at its left (`--icon-size-500`, `aria-hidden`, inheriting the chip's text colour — one tone for the whole note; none renders by default, the ai set's icon-free-unless-asked convention). The chip is one line: the text sits in its own span carrying `text-overflow: ellipsis`, so a note that outgrows the shell clips with an ellipsis and one that fits carries no trailing dots — an ellipsis is a truncation mark, not punctuation — while the icon never shrinks. Composer owns the chrome, the caller passes the text, and a click on it falls through the shell handler to focus the textarea like any other non-control surface. `onSubmit` reports the value but never clears it: the consumer owns the value and empties it after a successful submit. The textarea defaults to `aria-label="Message"` when the caller provides no accessible name; ref and unrecognised props land on the `<textarea>`, `className` on the shell.

The opt-in `aiGlow` prop dresses the focused shell in AiButton's exact signature — the system's "a model answers here" signal, for composers whose messages are answered by a model. On `:focus-within` the plain selected border goes transparent and two pseudo-elements play AiButton's entry: the blurred halo blooms out of the send-button corner (scale from a corner `transform-origin`, opacity from 0, `--motion-duration-slower` on `--motion-ease-entrance`) and the ring fades up a `--motion-duration-fast` beat behind it — transition-driven rather than AiButton's mount animation, because the composer wakes on a state change and must replay on every focus; the resting rules carry the way back, so losing focus collapses the halo quickly on `--motion-duration-base`. The pair: a conic-gradient ring in the `--color-ai-gradient-*` triple, masked to the same `--border-050` band as AiButton's ring, its outer edge on the shell's own border box — one step up from the shell's `--border-025` border, with the extra pixel growing inward so the geometry never shifts — and the same gradient blurred (14px, matched to AiButton) behind the opaque shell at 0.3 opacity so it reads as a halo around the edge. Both rotate in phase on AiButton's ambient cadence (the 21s three-uneven-turns loop), driven by a Composer-scoped registered `@property` angle so the component never depends on AiButton's stylesheet; browsers without `@property` render the ring static, and reduced motion keeps the ring but stops the turning. Off by default — the plain composer keeps `--color-input-border-selected` on focus, and a disabled composer keeps the pseudos dark even if a control inside it holds focus. `aiGlow` and `streaming` together define one more state, the working glow: while a response streams, the ring stays lit and keeps turning whether or not the field holds focus — the visible half of the streaming contract for glowing composers, carried by the same pseudo-elements with no new API. Either prop alone behaves as before, and the same reduced-motion rule stills the turn.

### DocumentChip

**`ds-document-chip`** — A compact file reference: a two-line tile with a type icon, name, metadata line, upload progress, and an optional remove button, for documents attached to chat messages or queued above a composer. The tile is `--color-bg-container-primary` behind a `--border-025` border at `--radius-300`, with the border colour routed through a local `--ds-document-chip-border` custom property so the error state repoints one variable to `--color-status-error-border` instead of restyling parts. A module-level map picks the Material Symbol for each of the eight `fileType` values (pdf, doc, sheet, slide, image, code, archive, generic) at `--icon-size-600`, overridable via `icon`; the name truncates with an ellipsis at a consumer-overridable `--ds-document-chip-max-width`, in `--font-paragraph-sm-emphasis-*`. `meta` is free text in `--font-paragraph-sm-*` `--color-text-tertiary` ("1.2 MB", "12 pages"), so callers keep their own formatting. `progress` swaps the metadata line for a composed compact ProgressBar with its percentage label; `error` replaces it with a `--color-status-error-text` message and colours the border and icon with the error pair. `size="compact"` tightens the padding and drops to one line — name only — for dense composer rows.

Passing `onClick` turns the body (icon + text) into a `<button>` inside the root `<div>`, so click and remove coexist without nesting interactive controls; the remove button carries `removeLabel` as its accessible name and every icon is `aria-hidden`. Chip stays the one-line `--radius-pill` pill for attributes and filters; FileInput stays the form control that owns selection — DocumentChip only references a file the host already holds.

### InterruptCard

**`ds-interrupt-card`** — A human-in-the-loop checkpoint: a question from the agent with option buttons for the person to decide ("Allow file edit?" — allow once, always allow, deny). It shares ToolCall's skeleton and footer: the same neutral `--color-bg-container-primary` container at the chat furniture's shared `--radius-600` shell behind a `--border-025` `--color-bg-container-border`, and an options footer with ToolCall's exact actions-footer values (the card's own background, flex-start at `--gap-200`, `--padding-200`/`--padding-500`/`--padding-400`, separated from the body by whitespace alone — no rule), so a decision on a card and an approval on a call read as the same row. The card is deliberately single-tone in both states — no status colour on icon or border; the question and its buttons are the signal, and a visually hidden status announcement carries the waiting state. The icon sits in `--color-icon-secondary` via the local `--ds-interrupt-card-color`. Once `value` is set the decision is made: the options are replaced by a quiet echo of the chosen label behind a check icon, on the same footer padding (the `--answered` modifier stays on the root as a consumer styling hook). The header stacks a `--font-heading-3-*` title (the question is the card's one heading, so it takes the same heading tier as MessageCard's title — the chat cards share one title scale) over a `--font-paragraph-sm-*` `--color-text-secondary` description, beside an optional Material Symbol at `--icon-size-500` — none renders by default, keeping the ai cards icon-free unless a caller opts in.

Options are structured data (`{ value, label, variant }`), not a free slot, because the options *are* the component — each renders as a compact Button (`primary` for the recommended choice, `danger` mapping to Button's destructive variant) firing `onValueChange` with its value. The card is fully controlled and holds no state. Freeform "tell it what to do instead" input is deliberately out of scope in v1; compose richer detail via `children`, whose intended pairing is `<InterruptCard><ToolCall status="pending" /></InterruptCard>` — the question above, the exact call it covers below. The root is `role="group"` named by the title, with a visually hidden `role="status"` "Waiting for your decision" while unanswered.

### MessageActions

**`ds-message-actions`** — The icon-button row for message-level actions: copy, retry, feedback. Built to slot into ChatMessage's `actions` prop, which reveals it on hover and keyboard focus, so the row's own job is just the buttons. Each item is a ghost button — no border, transparent at rest, `--radius-200` with `var(--padding-100)` padding around a Material Symbol at `--icon-size-500` in `--color-icon-primary` — hovering onto `--color-action-passive-bg-hover` and `--color-text-primary` over `--motion-duration-fast` `--motion-ease-standard`. The row itself is a flex line at `--gap-050`. An `active` item (the chosen feedback thumb) holds `--color-action-passive-bg-active`; focus takes the house ring inset (`outline-offset: -2px`), disabled the house 0.4 opacity. These stay ghost buttons rather than CircularButtons because a resting surface per icon would give a quiet utility row four competing pills.

Every button carries its item's `label` as `aria-label`, and by default a composed Tooltip shows the same label on hover and focus (`showTooltips={false}` drops the tooltip, never the name). `aria-pressed` renders only when an item sets `active` — a toggle-like action announces its state, a plain command like copy stays a plain button. The row is stateless beyond hover: copy feedback is the consumer swapping the item's `icon` to `check` (and its `label` to match) for a moment, so the component never owns a timer or a clipboard call — `onActionClick` fires with the item's stable `id` and the consumer decides everything else.

### MessageCard

**`ds-message-card`** — A structured rich-content card embedded in a chat message: media, title, body, and an actions row, for link previews, search results, and booking-style rich responses inside an assistant turn or bubble. Card is the navigation tile you click to go somewhere; MessageCard is content furniture inside a conversation — but the two share one card geometry, and MessageCard's `--radius-600` shell matches the chat bubbles around it. The container is `--color-bg-container-primary` behind a `--border-025` `--color-bg-container-border` border. The optional media slot is inset, not full-bleed: `--padding-300` (12px) of card around a `--radius-300` (12px) slot keeps its corners concentric with the card's 24px shell — the exact geometry of Card's case-study cover, and the same rule the Composer follows (images inside render block at full width). The slot carries its own `--color-bg-page-primary` surface (lightest in light mode, darkest in dark), so drawn media — a chart, an SVG — reads as the same image-shaped panel a photo would, on a ground that recedes rather than tints. A Chart placed in the slot sheds its own panel chrome (background, border, radius, most of its padding) — the card is already the panel, and stripping the chart's container tone keeps the drawing at maximum contrast on the slot's page tone. The body stacks at `--gap-200` inside `--padding-400` top / `--padding-500` side / `--padding-200` bottom padding (`--padding-400` bottom when no actions follow), so the media-to-body distance equals the body-to-actions distance and the copy sits centred between image and CTA row. It opens with a header unit — an optional Material Symbol at `--icon-size-500` `--color-icon-primary` beside a `--font-heading-3-*` title, with the free-text `meta` line ("transit.example", a date) in `--font-paragraph-sm-*` `--color-text-tertiary` tucked `--gap-100` beneath it as the title's subline — then a description in `--color-text-secondary`, then any children — a Prose block, a DocumentChip row. The card claims no width of its own: the bubble or turn constrains it, with `--ds-message-card-max-width` as the consumer-overridable cap.

The `actions` footer mirrors ToolCall's actions footer exactly — the card's own background, flex-start at `--gap-200`, `--padding-200`/`--padding-500`/`--padding-400`, separated from the body by whitespace alone rather than a rule — so approvals on a tool call and link actions on a card read as the same row across the ai components.

### ModelPicker

**`ds-model-picker`** — The model selector for a chat surface, built for Composer's `actions` slot but freestanding anywhere. The trigger is a quiet `--radius-pill` pill on `--color-action-passive-bg` (`--font-paragraph-sm-emphasis-*` in `--color-text-secondary`, rotating `expand_more` chevron) — deliberately chrome, not a call to action, so the send button keeps the only teal in the composer. It stands CircularButton's 40px tall (`0 var(--padding-400)` horizontal padding), so the pill and the composer's round icon buttons read as one row of equals. The panel is the floating-surface recipe (`--color-bg-page-primary` — the opaque page fill the floating menus and panels share, because the container fill is semi-transparent in dark mode and would show text through — hairline border, `--radius-300`, `--shadow-floating`, min-width 260px) and `placement` flips it above the trigger for composers pinned to the bottom of the screen.

Models list as a `role="listbox"` of rows — `--font-paragraph-sm-emphasis-*` name with an optional `--color-status-info-bg` badge pill, a tertiary description line, and a check in `--color-action-primary-text-tertiary` on the selected row — with Dropdown's keyboard model (arrows move, Home/End jump to the ends, Enter selects, Escape closes, outside click dismisses) and `aria-activedescendant` tracking the highlighted row. A disabled entry stays visible in the list rather than vanishing: it takes the house disabled treatment (opacity 0.4, `cursor: not-allowed`) with `aria-disabled` on the row, clicks are suppressed, and arrows and Home/End skip over it — which is what lets a host grey a model out while keeping the lineup readable (the site chat's budget lock leans on exactly this). When an effort value is provided (`effort`/`defaultEffort`/`onEffortChange`), an effort row appears below a `--color-divider` rule: an "Effort" label beside a `role="radiogroup"` of pills in a `--color-bg-container-secondary` track, the chosen one lifted on `--color-bg-container-primary` with `--shadow-floating` — the SegmentedControl treatment at menu scale. The radiogroup roves its tabindex from the checked pill: one tab stop, arrows move focus and selection together. Effort levels default to Low/Medium/High and are overridable via `effortOptions`. Model and effort selection both follow the controlled/uncontrolled pair convention.

### PromptSuggestions

**`ds-prompt-suggestions`** — A horizontal row of tappable prompt suggestions to start or steer a conversation. Each suggestion renders as a clickable Chip, so one component owns the pill look — the row only arranges them and never restyles the pills. `size` picks which Chip size to ask for, one step above Chip's own scale because a conversation starter is something to tap rather than metadata about something else: `default` renders `large` chips at the body-paragraph scale, so a suggestion reads at the same weight as the message it will become, with a `--gap-300` gutter; `compact` renders Chip's default 32px pill at `--gap-200`, for rows sitting alongside a live conversation. `layout` picks one of three arrangements, named on the element rather than inferred so each owns its own rules. `scroll` (the default) is a single line (`overflow-x: auto`) whose edges fade out through a pure-CSS `mask-image` gradient with `--gap-500` stops: the fade is the overflow hint, with no scroll listeners and no JS measurement. `wrap` trades that for `flex-wrap` across multiple lines, the empty-state hero placement where everything is visible and there is no overflow to hint at. `stack` gives each suggestion its own line for narrow columns, where a wrapped row breaks wherever the labels run out of room and the ragged edge reads as an accident; the pills still hug their labels (`align-items: flex-start`), since stretching them to a shared width would make a set of prompts look like a set of buttons. `padding-block: var(--padding-050)` keeps chip focus rings clear of the scroll clip. The boolean `wrap` prop is a deprecated alias for `layout="wrap"`, ignored when `layout` is set.

The root is `role="list"` (named by `ariaLabel`, default "Suggested prompts") with each chip in a `role="listitem"` wrapper, so a screen reader announces how many suggestions there are before reading any. The chips themselves are real buttons — Chip's clickable mode — which also keeps the scrollable region keyboard-reachable. Tapping one fires `onValueChange` with the suggestion's stable `id`, never its display text.

`pending` swaps the suggestions for placeholder pills (`ds-prompt-suggestions__skeleton`, `pendingCount` of them, default 3): the chip's real shell — its `--color-bg-page-primary` surface, `--border-025` `--color-bg-container-border` border, and the current size's padding and computed height at `--radius-pill` — holding a text-height shimmer bar (`ds-prompt-suggestions__skeleton-bar`, ThreadPanel's 0.75em `--color-bg-container-secondary` bar with Skeleton's sweep on `--motion-duration-loop-shimmer`) where the label will be, so what reads as loading is the label generating inside a finished pill, and the row is exactly as tall pending as loaded. Shell widths cycle through three em values so the set reads as questions of different lengths rather than identical pills. The placeholders are `aria-hidden` and the root reports `aria-busy`, so assistive technology hears a busy list rather than three empty items — the `aria-busy` is load-bearing, exempting the momentarily item-less list from the required-children rule. The site chat uses the state both ways: honestly under a finished answer while its follow-up questions are generated, and staged for `MOTION_SUGGESTIONS_THINK_MS` when the welcome screen's pre-written starters swap on navigation — the summary panel's think beat applied to chips.

Items pop in one after another on mount — a `--motion-duration-slow` rise-and-settle (`translateY` + slight scale) on `--motion-ease-spring`, staggered one `--motion-duration-instant` per pill and capped at the fifth so a long scrolling row does not keep its far end waiting, `backwards`-filled so a delayed pill waits unseen for its turn. The pending placeholders and the chips that replace them are both items, so each set lands one-two-three; every timing is a token, so the reduced-motion collapse stills the entrance with everything else. `entrance={false}` suppresses the pop-in for a set that has already landed — a host re-showing suggestions the visitor watched arrive (the site chat reopening over the same page's starters, or over follow-ups from earlier answers) renders them standing rather than arriving twice.

Tapping a chip plays an acknowledgment beat before the host hears about it: the chosen chip presses in and springs back (`--motion-duration-slow` on `--motion-ease-spring` — the overshoot is the bounce) while its siblings dim to the system's 0.4 convention on a `--motion-duration-base` opacity transition, and `onValueChange` fires `MOTION_EXIT_SYNC_MS` after the tap — so a host that unmounts the row on selection (the chat sending the suggestion) still lets the choice be seen. One choice at a time: taps are ignored while a beat is in flight, which also guards a double-tap firing twice; the chosen state clears when the pulse ends, so a row that persists (follow-ups under an old answer) returns to normal and stays tappable.

### Prose

**`ds-prose`** — Token-styled typography for rendered markdown and rich agent output. The package ships no markdown renderer: consumers render markdown with whatever library they already use and wrap the output in Prose, which styles the descendant elements through scoped selectors. Body text sits on the `--font-paragraph-*` scale in `--color-text-primary` with `--gap-400` between blocks, edges trimmed (`first-child`/`last-child` margins zeroed) so it slots into bubbles and cards cleanly. Headings step down the token tiers — `--font-heading-1-*` through `--font-heading-3-*`, then `--font-title-body-*` for `h4` — each applying its full five-property set, so the weight-contrast principle holds without Prose restating it. Links use `--color-action-primary-text-tertiary` with an underline; `strong` takes `--font-paragraph-emphasis-weight`; lists indent by `--padding-400` with `--gap-100` between items.

Inline code and `pre` both use `--font-family-code`: inline code sits on `--color-bg-container-secondary` at `--radius-050` with `--padding-050` horizontal padding, one size step down; `pre` is a `--color-bg-container-primary` panel with a `--border-025` `--color-bg-container-border` border at `--radius-300`, `--padding-400`, scrolling horizontally rather than wrapping, and `pre code` drops the inline chrome. Blockquotes take a `--border-050` `--color-divider` left rail with `--padding-400` inset in `--color-text-secondary`, mirroring Reasoning's trace rail. Tables use collapsed borders, `--font-title-body-*` headers on `--color-bg-container-secondary`, and `--border-025` `--color-divider` row rules. They are their own scroll container: `display: block` with `width: max-content`, `max-width: 100%` and `overflow-x: auto`, so a table wider than its container scrolls sideways instead of being clipped. Prose styles markup it does not render and so cannot add a wrapping element, and the price of making the table element the scroller is that it sizes to its content rather than filling the container, since the anonymous table box inside a block table cannot be stretched. Cells also set `overflow-wrap: normal`, which holds each column's minimum width at its longest word no matter what the surrounding context does; without it an ancestor's `overflow-wrap: anywhere` (ChatMessage sets it so a long URL cannot widen a bubble) inherits in, collapses every column to a single character, and auto table layout squeezes headings into stacks of letters instead of letting the table reach its natural width. Consumers who want the scroll reachable by keyboard alone should put `tabindex="0"` on the table, which is markup only the consumer can supply; `role="region"` belongs on a wrapping element if the scroller also needs an accessible name, never on the table itself, where it would replace the table semantics. `hr` is the same hairline; images cap at `max-width: 100%` under `--radius-300`.

`size="sm"` moves the body scale to `--font-paragraph-sm-*` for dense chat contexts while headings keep their tiers. Prose adds no roles or behaviour of its own — the semantics are the consumer's markup — and its `pre` is the plain fallback: CodeBlock remains the richer choice for standalone code.

### Reasoning

**`ds-reasoning`** — A model's thinking, disclosed behind a one-line summary. The trigger is a borderless `--font-paragraph-*` button in `--color-text-secondary` (stepping to `--color-text-primary` on hover) with an `expand_more` chevron *after* the summary — trailing, like ToolCall's, so the summary line starts at the same left edge as everything around it — rotated −90° to point at the collapsed trace, turning back to pointing down as the panel opens, the same closed/open convention CodeBlock's chevron uses. `summaryOnly` drops the chevron, the trigger, and the panel, leaving the summary line alone — for a model that reports what it is doing but produces no trace to read, where a disclosure would open onto nothing. The line keeps the trigger's own geometry, so a trace arriving mid-response promotes it to a real disclosure without moving anything. The panel collapses with the `grid-template-rows: 0fr → 1fr` technique over `--motion-duration-slow`, and toggles `visibility` alongside it so collapsed content leaves the accessibility tree. The trace sits on a `--border-050` `--color-divider` rail in `--color-text-secondary` — the rail dissolves to the transparent container colour at its foot, the same end-of-line treatment as Timeline's last stem — its geometry derived from an AgentStatus summary: the rail runs down the centre of the matrix (half an `--icon-size-600`, corrected by half the rail width) and the trace text starts exactly where the status label's first letter starts (`--icon-size-600` + `--gap-300`; the compact size derives the same sums from `--icon-size-500` + `--gap-200`). A plain text summary shares the same geometry, so swapping a live status for the thought-for line never shifts the trace — quiet enough never to compete with the answer beside it. The text scale pairs with ChatMessage: the default size matches default message text, and `size="compact"` drops trigger and trace to `--font-paragraph-sm-*` to match compact message text.

`streaming` opens the panel and shimmers the summary with the same treatment AgentStatus uses, so "the model is working" looks the same wherever it appears; when the stream ends the panel collapses to `Thought for {duration}s`. That auto-collapse yields to the reader — once someone has toggled the panel themselves, the stream ending no longer moves it. Only the summary is a `role="status"` live region: a trace announced token by token floods a screen reader, so the body stays ordinary expandable content and the announcement covers the boundaries.

The `summary` slot replaces the line with custom content — designed for an AgentStatus, so the live indicator and the trace disclosure work as a pair: AgentStatus-with-trace while the agent runs, the quiet thought-for line above the finished response. A node summary owns its own appearance and announcement, so Reasoning's text shimmer and live region stand down (AgentStatus shimmers and announces itself). Dropping the `summary` prop animates the handoff itself: the departing node fades out in place while the text summary slides left from the label position into the flush edge, the distance derived from `--ds-reasoning-align` (matrix + gap, the same sum the rail geometry uses) — consumers flip `streaming` off and drop the prop; the transition is the component's job.

Inside the panel, a list is the trace's node list: one thinking step per item, no bullets (the rail is the marker), `--gap-500` between nodes — the same beat the panel opens with — and new nodes rise in as they mount, so a trace that builds while an agent works animates without any consumer wiring.

### SourceChip

**`ds-source-chip`** — A numbered citation pill linking a claim to its source: a borderless `--radius-pill` pill on `--color-bg-container-secondary` holding a leading slot and a source title in `--font-paragraph-sm-*` `--color-text-secondary`, the title truncating with an ellipsis at `24ch`. The leading slot is either the citation number — a `--font-paragraph-sm-emphasis-*` `tabular-nums` numeral in `--color-text-tertiary`, centred in its own `--icon-size-500` `--color-bg-container-tertiary` circle — or a Material Symbol at `--icon-size-500` `--color-icon-primary`; when both `index` and `icon` are passed, the index wins.

The root is an `<a>` when `href` is set and a plain `<span>` otherwise. Only the link gets interaction styling: on hover the background steps to `--color-bg-container-tertiary` and the text to `--color-text-primary` over `--motion-duration-fast` `--motion-ease-standard`, with `text-decoration: none` throughout — the pill shape is the affordance — and focus draws the house `--color-action-primary-bg` ring. The non-link span keeps `cursor: default` and no hover. It renders inline after a sentence or in a wrapping sources row under an assistant answer, pairing with ChatMessage's footer slot.

### SourceTrail

**`ds-source-trail`** — The research trail under an agent's answer: the sources it opened, in order, each row a SourceChip with a status indicator and an optional contribution note. The shell is ToolCall's exactly — `--color-bg-container-primary` behind a `--border-025` `--color-bg-container-border` at the chat furniture's shared `--radius-600`, header and list inset `--padding-500` from the sides, the panel on the same `0fr → 1fr` grid collapse with `visibility` handling — so a collapsed trail reads as one more row in a run of tool calls rather than a different species. The header is a disclosure button carrying a computed count ("Reading 3 sources" while anything is pending or active or while `streaming`, "Read 3 sources" once settled; `title` overrides it) in `--font-paragraph-sm-emphasis-*`, a leading indicator (a `variant="inherit"` Spinner while unsettled, `travel_explore` at `--icon-size-500` once done), and ToolCall's rotating `expand_more` chevron.

Rows are an `<ol>`: per-item status follows AgentPlan's vocabulary — `circle` for `pending` (the whole row at 0.6 opacity: not opened yet), a Spinner for `active` in `--color-status-info-icon`, `check_circle` for `done` in `--color-status-positive-icon` — each with visually hidden status text announced beside the title. The chip is a real SourceChip: `href` makes it a link (opened in a new tab with `rel="noopener noreferrer"`), `icon` fills its leading slot, and the `detail` note truncates in `--font-paragraph-sm-*` `--color-text-tertiary`. While `streaming`, an appended row eases in over `--motion-duration-slow` `--motion-ease-standard` — pure CSS, so the reduced-motion token collapse stills it; there is deliberately no JS-timed stagger. Disclosure is the established trio (`open`/`defaultOpen`/`onOpenChange`, default open), with `useId`-derived trigger and panel ids.

### StreamingText

**`ds-streaming-text`** — The reveal for text that arrives in chunks: an LLM response typing itself out. It owns no text style — `white-space: pre-wrap` and `overflow-wrap: break-word` on an inline span that inherits the surrounding typography, so it drops into a ChatMessage or Prose paragraph without changing its face. Feed it the accumulated `text` on every render and it animates through what was appended from a `requestAnimationFrame` loop that spends real elapsed time, so the pace is frame-rate independent and a fast stream renders at most once per frame. The rate scales with the backlog rather than being fixed: a thin trickle types at `MOTION_STREAM_FLOOR_CPS` (70 characters per second, from `tokens/motion.ts`) and however much is waiting is fully on screen within `MOTION_STREAM_DRAIN_MS` (250ms), so a dumped paragraph catches up in a beat instead of typing for seconds; `floorCps` and `drainMs` tune both per instance (`charIntervalMs`, the retired interval prop, maps to the floor). A value that does not extend the previous one is a new message and reveals from zero, while text present on mount shows whole. The engine is published headless from the same folder — `useStreamReveal` (declarative) and `createStreamReveal` (imperative), via `components/StreamingText/useStreamReveal` — for a consumer whose streamed text needs its own renderer: the site chat paces its markdown through `createStreamReveal` and renders it itself. The cursor is a `0.55em × 1em` `currentColor` block at `--radius-050` blinking on a stepped opacity keyframe at `--motion-duration-loop-spin`, shown while `streaming` or while the reveal is behind. Under `prefers-reduced-motion` the JS reveal is skipped (each chunk appears whole) and the token collapse stills the cursor. `onRevealComplete` fires once when the reveal catches up after streaming ends. Announcement is the container's job — pair with `aria-live` when the surrounding UI does not already announce the message.

### ThreadPanel

**`ds-thread-panel`** — The session-history rail of a chat or agent product: brand header, a new-thread action with an optional shortcut hint, standing control rows, the grouped thread history, and a footer for the profile row and host furniture. It began as the agent-workbench template's threads panel and graduated once the shape proved out. Deliberately **surface-less**: the host paints the band it sits on (the workbench template lays it on its own zone tones), so the component fills its container's height and scrolls exactly one region — the history `<nav>`, behind a tucked-away thin scrollbar (`scrollbar-width: thin`, no track fill) that only shows its `--color-divider` thumb under the pointer.

The rows are AppSidebar's shared pill shape with the inset moved up a level: the panel itself carries `--padding-300` inline padding in both states, and every 40px `--radius-pill` row fills the padded column at `width: 100%` — one hover shape for the whole surface, every fill stopping at the same 12px breathing room. Expanded, rows take `--padding-400` inline padding, setting the 28px optical seat (12 + 16) the whole panel aligns to — the pen icon's box is the guide for group labels, titles, and the profile alike; collapsed they return to `--padding-200`, which is what the circle's math needs (12 + 8 + 24 + 8 + 12 = the 64px rail), the padding gliding with the rail's sweep on `--motion-duration-slow` `--motion-ease-emphasized`. Rows sit one `--gap-100` beat apart, hover washing to `--color-action-passive-bg-hover` over `--motion-duration-fast` `--motion-ease-standard`, focus drawing the house `--color-action-primary-bg` ring inset. The new-thread row is deliberately as quiet as the control rows — the leading position and the glyph carry the affordance, not a fill — wearing `edit_square` by default, the same pen-in-a-box chat headers use for New chat (`newThreadIcon` overrides), with compact Kbds for the decorative shortcut hint (the host owns the actual key binding). Control rows carry `--color-icon-primary` icons and `--font-paragraph-sm-*` labels. Thread rows share the action rows' 40px shape exactly, stacked a hairline (1px) apart so adjacent fills never merge into a slab: a one-line title and an optional trailing cluster — a `--color-icon-secondary` session glyph (`icon`, e.g. `cloud`), a `keep` pin mark (`pinned` — a marker only; the host groups pinned threads and offers pin/unpin through the row menu), and the `meta` caption in `tabular-nums` — with an optional `unread` status dot leading the title (a `--gap-200` disc of `--color-status-info-icon`; the gap scale because no icon token is that small). A `description` puts a quiet second line under the title — the `--font-caption-*` face on `--color-text-tertiary`, the row wrapping to `height: auto` at `min-height: 40px` with its gap gone column-only so the lines sit close, the second line indented past the dot's column when both appear — under the same trailing mask as the title, so detail rows and plain rows fade out the same way. A long title dissolves under a trailing `--gap-1000` mask (the alpha-keyword gradient, ChatThread's manner) rather than clipping to an ellipsis, with the full text in the row's native tooltip — the fade is also where the overflow trigger lands, so revealed text and control never fight. The active row (matched by the controlled `activeThreadId`) fills the column with `--color-bg-container-tertiary` and announces `aria-current`. Group labels are the `--font-overline-*` face aligned to the rows' icon seat, carrying the rows' own `--padding-200` vertical inset — as if the label sat inside a row — so the overline keeps the pills' rhythm instead of hugging its neighbours; the more row is a quiet `--font-caption-*` reveal that brightens one text step on hover. The brand header takes a `--gap-300` bottom margin, seating the logo evenly between the panel's top edge and the new-thread row.

Thread rows carry a real history's lifecycle, all of it controlled from outside. A `pending` thread is one whose name is still being generated: a full-width `--radius-pill` shimmer bar at 0.75em height, inset only by the row's padding and softened at its trailing end by the title mask (`--color-bg-container-secondary` under Skeleton's sweep at `--motion-duration-loop-shimmer`; the component restates the sweep rather than composing Skeleton, whose `role="status"` label would hijack the row's accessible name) holds the title's place, the given title — typically "New chat" — stays as the row's tooltip and accessible name, and the row shows no menu until the name lands. `threadActions` (with `onThreadAction`; a thread's own `actions` overrides the set) hangs an overflow menu on every row: a 28px circular `more_horiz` trigger absolutely positioned over the faded trailing edge — the `<li>` is the positioning frame, since a control cannot nest inside the row button — opening the compact DropdownMenu aligned end, destructive rows in the menu's own error styling. The trigger rests at opacity 0 with `pointer-events: none` and reveals only on row hover, on `:focus-within`, and while its menu is open (held via `:has([aria-expanded='true'])` — the value is load-bearing, since the closed trigger carries `aria-expanded="false"` — which also keeps the row's hover wash on) — never standing on a resting row; the trailing cluster (glyph, pin, meta) fades out as the trigger fades in, since they share the trailing edge. `renamingThreadId` swaps that row to the inline rename field: an uncontrolled input wearing the row's exact shape and `--font-paragraph-sm-*` face with a `--border-025` `--color-action-primary-bg` border (the active-input teal), prefilled and selected on mount. Enter commits the trimmed value through `onThreadRename`, Escape cancels through `onRenameCancel` (both hand focus back to the row), focus leaving the field commits too, and an empty or unchanged value cancels rather than committing — half a title is not a title.

Between the standing controls and the history sits the optional projects section: rows sharing the controls' exact anatomy (`folder` by default, any Material Symbol via `icon`, an optional `meta` caption that fades with the labels) under their own `--font-overline-*` header carrying the group labels' seat — its vertical inset stepped down to `--padding-150` so the 24px new-project button (`add`, rendered only when `onProjectCreate` is wired) doesn't grow the header past the label-only group rows below. The active project (matched by the controlled `activeProjectId`) takes the same `--color-bg-container-tertiary` fill and `aria-current` as an active thread. Collapsed, the header fades out whole (opacity plus `visibility`, the history's manner) while the project rows fold to icon circles like the controls, keeping the section present on the rail.

Fully controlled and stateless — the host owns selection, navigation, the expanded state, the pending flags, and the rename state. Every row follows Button's element pattern (`<button>`, or `<a>` when an `href` is given), each section renders only when its props do, and the component carries no `'use client'`, so a link-driven history renders from a Server Component; the menu and rename affordances take callbacks, so they belong to client hosts by nature. The footer composes Avatar for the profile row; `footerSlot` seats host furniture like a theme toggle above it.

Collapse is AppSidebar's choreography wholesale: `expanded` (default true) with `onExpandedChange`, whose presence renders the header's toggle (`left_panel_close` / `left_panel_open`, announcing `aria-expanded`). The panel owns its width — 280px expanded, the 64px rail collapsed, AppSidebar's own constants — transitioning on `--motion-duration-slow` `--motion-ease-emphasized` while every label stays mounted and fades on `--motion-duration-base` `--motion-ease-standard` under the sweeping clip, so inner layout never snaps. Collapsed, the toggle folds to zero width and the logo takes over as the expand button (AppSidebar's `role="button"` pattern; with no logo the toggle stays standing and centres on the rail's axis, the empty brand slot standing down) — and the rail's dead space is an expand target too, a click anywhere that is not a row reopening the panel — the rows read as circular icon buttons, the `footerSlot` fades out, and the history — whose thread rows have no icons to collapse to — fades and leaves the accessibility tree and tab order via `visibility`. The host pairs the state with its own band, which simply follows the panel's animating width.

### ThreadTabs

**`ds-thread-tabs`** — The strip of open chat sessions across a conversation's top edge: 32px `--radius-pill` pill tabs with an optional leading glyph (`--icon-size-500`, `--color-icon-secondary`), ThreadPanel's `--gap-200` unread dot (`--color-status-info-icon`), a `--font-paragraph-sm-*` label, a hover-revealed close button, and a trailing 28px new-tab action. Deliberately **surface-less** like its sibling rail — the host paints the band it sits on. Labels sit on `--color-text-secondary` and step up to `--color-text-primary` on the active tab, whose pill fills with `--color-bg-container-tertiary` — the same "on stage" mark as ThreadPanel's active row, so the strip and the rail speak one language. Hover washes `--color-action-passive-bg-hover`; a long label caps the tab at 220px and dissolves under a trailing `--gap-400` mask with the full text in the tab's tooltip (the panel's manner); a crowded strip scrolls sideways with no scrollbar. Not an ARIA tablist: like the panel's rows these are a labelled list of buttons selecting sessions, the active one announcing `aria-current`. Arrow keys, Home and End move focus along the strip; Delete closes the focused tab when `onTabClose` is wired.

The close button is geometry-stable — a 24px circle that always holds its seat and only fades in (`--motion-duration-fast`) on tab hover, keyboard focus within, or the active tab, so revealing it never shifts the label; closing from the keyboard hands focus to a neighbour before the tab leaves. Enter and exit are the component's own choreography, the grid-track idiom turned sideways: each item is a one-column grid animating between `0fr` and `1fr` (with its own leading `--gap-100` margin folding in and out), so an added tab glides open in place on `--motion-duration-slow` `--motion-ease-entrance` and a removed one — held on stage in a leaving list until its animation ends — folds away on `--motion-duration-base` `--motion-ease-emphasized`, the neighbours sliding over as the column collapses. No measurement, no FLIP; the initial set renders calm, only tabs that arrive later animate. Fully controlled: the host owns which tabs exist and which is active.

### ToolCall

**`ds-tool-call`** — The record of one tool invocation. A skimmable header row — status indicator, monospace tool name (`--font-family-code`, the same sanctioned monospace context CodeBlock uses), summary, status word, `tabular-nums` duration, chevron — over a collapsible body holding the arguments and result. Container is `--color-bg-container-primary` at the chat furniture's shared `--radius-600` shell with a `--border-025` `--color-bg-container-border`, header and body inset `--padding-500` from the sides; a closed row rounds to a capsule the way a one-line bubble does, echoing the system's pill buttons. The panel uses the same `0fr → 1fr` collapse and `visibility` handling as Reasoning.

Four statuses: `pending`, `running`, `success`, `error`, mapping to the warning, info, positive and error roles through two channels — the status word in the `--color-status-*-text` tokens, the indicator glyph in the `--color-status-*-icon` tokens (the plain-surface step; see the Status section). The container stays neutral so a long run reads as a list rather than a wall of tinted cards — the border is the standard container hairline in every status, and the status word and icon carry the state. `running` renders a `Spinner` at `variant="inherit"`, the rest a Material Symbol (`pause_circle`, `check_circle`, `error`) at `--icon-size-500`.

The `actions` slot — allow, deny, always allow — renders in a single-tone footer on the card's own background, separated by whitespace alone, *outside* the collapsible panel, so answering an approval request never requires expanding the call first. With no children the header renders as a plain `div` rather than a button, so a row with nothing to disclose does not look pressable.

### UsageCard

**`ds-usage-card`** — An agent's budgets at a glance: the context window plus any number of plan or rate limits, one compact Meter row each, with reset captions. It wears the chart family's shared card chrome — the root composes `ds-chart` (and `ds-chart--bare` via `bare`), the header is `ds-chart__header` with the family's title and subtitle styles — the Gauge/FunnelChart precedent of chart chrome outside the `Chart/` folder, though the component registers under the ai category because its vocabulary is context windows and agent budgets. Rows stack at `--gap-500`; each pairs a `size="compact"` Meter (label left, readout right, `valueText` overriding the percentage for token counts and spends) with an optional `resetLabel` caption on the bar's trailing edge in `--font-caption-*` `--color-text-secondary`.

Fills recolour through Meter's status variants as a level crosses the card's thresholds — `info` until `warning` at 0.8 of max, `error` from 0.95, both fractions tunable via `thresholds`, mirroring Gauge's threshold idea — and an item's own `variant` pins the derivation aside (a frozen quota can stay `neutral` at 100%). Everything visual belongs to Meter and the chart chrome, so the component adds no tokens and no motion of its own; the a11y contract is Meter's `role="meter"` per row. Deliberately server-renderable: no `'use client'`.

### Waveform

**`ds-waveform`** — Voice made visible: a row of `--radius-pill` bars that dance while audio is happening. The animated states are pure CSS choreography on the shared twelve-slot cycle (`--motion-duration-loop-matrix`, the same loop AgentStatus's dot patterns run on): each bar carries a centre-weighted peak (`--ds-waveform-amp`, outer bars reaching lower so the wave reads as one shape) and a cycle slot (`--ds-waveform-step`, adjacent bars two slots apart, applied as a negative delay so the peak travels the row and every bar is mid-wave on mount). `state`: `idle` parks the bars low in `--color-text-tertiary`; `listening` runs the wave at roughly half height; `speaking` at full — one keyframe set, scaled per state through `--ds-waveform-peak`. `levels` switches to controlled mode for a real analyser: the CSS wave stops and each bar eases to its 0–1 level over `--motion-duration-fast`. Deliberately monochrome like the agent working states (`--color-text-secondary`), tintable through `--ds-waveform-color`. Height rides the icon scale (`--icon-size-600`, `compact` on sm) and bar width derives from it. Root is `role="status"` with a visually hidden state label (defaults per state, `label` overrides); the bars are `aria-hidden`. Under reduced motion the wave parks on its resting contour rather than strobing. Purely presentational — no `'use client'`.

### Site chat (pattern)

The site-wide chat is a pattern, not a component: `ChatHeader` + `ChatThread` + `ChatMessage` + `Composer` + `ModelPicker` + `PromptSuggestions` + `Reasoning` + `AgentStatus` + `CircularButton` + `Prose`, composed in the website's `SiteChat` module and mounted once from the root layout so the conversation, an in-flight stream, and the draft survive client-side navigation.

The composer's leading action is a live `ModelPicker` over the site's model allowlist. The pick is a request, not a command: it rides on each send, and the server resolves it through the day's budget tier before calling a model. The picker shows the visitor's pick, or the server's current default when they have not made one — which is how a budget step-down moves the label with no click — and an entry the budget has locked stays listed but disabled, its description swapped for the reason, so the lineup reads as "paused", never "gone".

The widget also carries an optional session-history slot: `threads`, a render prop handed `{ overlay, close }`, whose content is a host-shaped ThreadPanel. The widget measures its own width (a ResizeObserver on the threads host) and seats the panel by what it finds: at 720px and up the panel is an inline rail beside the conversation column, below that it lives behind a header `menu` CircularButton as a scrim-backed slide-over sheet — so the mode follows the actual container, not the viewport, and the docked site panel and a thin playground card get the sheet while the takeover and wide cards get the rail. Growing into rail territory retires the sheet, so it cannot pop back open on a later trip below the breakpoint; Escape settles the sheet before the panel, one layer per press; and with the inline rail showing, the header's `edit_square` New chat stands down, because the rail carries its own — the same action twice would be chrome. The slot is dormant on the live site: only the playground's Simulated transport stages it, where the Chat view drives the full thread lifecycle through it (the pending shimmer, the overflow menu with pin/unpin regrouping, the inline rename, the projects section — ThreadPanel's own spec owns those behaviours). A second slot, `tabs`, seats a ThreadTabs strip as its own row under the header on the widget's shared side inset — playground furniture the same way, staged on the desktop card only, where tabs hold thread ids: selecting a thread opens its tab, a fresh thread arrives with one, and closing the last tab returns to the greeting. A third, `aside`, is the trailing edge's mirror of `threads`: a host-shaped AgentRail, seated inline as a right rail once the widget measures 860px or more (its own threshold, because a 360px column would leave nothing for the conversation at the history's 720; the playground's default desktop card clears it by design) and otherwise raised from the header's `smart_toy` button as a **bottom** sheet — the edge a phone expects a secondary surface to come from, and the shape the rail's own column already is — wearing the threads sheet's glass and scrim, capped at 82% of the widget's height so the last turns stay visible above it, and marked with a decorative grabber (dismissal is the scrim's and Escape's job). Escape settles whichever sheet is standing before the takeover or the host panel hear anything, one layer per press. The panel is one piece of state, resting closed, and it opens on a click and nothing else: the seat decides how it is drawn, never whether it is up. Crossing out of rail territory closes it rather than handing it to the other seat — shrinking out of a takeover would otherwise turn an inline rail into a sheet over the conversation, which is not what the visitor asked for when they opened it — and growing back in does not reopen it, so the widget never puts a panel on screen by itself. One control, both ways: the header's `smart_toy` button holds its seat whether the panel is up or not and says which. Open, it fills with `--color-bg-container-secondary` — the panel's own surface colour, not a separate selected tint — so the pressed button reads as a piece of the thing it opened and follows it through a theme switch for free. A toggle that vanishes when it is on is a toggle nobody learns, which is why the widget's hosts leave AgentRail's own `onCollapse` unwired: with a standing toggle in the header, a second control on the panel is the same action twice. The header's far-right cross stays what it always was: the way out of the chat. With the rail seated inline the whole cluster leaves the chat header for the card's own right edge, floating over the rail's top inset (empty by construction, since the rail's header is a centred block starting below it) and ahead of the rail in the DOM as well as above it on screen — every control in it acts on the widget, so a cluster stranded mid-card above the conversation column would read as that column's chrome. The rail's chevron joins that row rather than sitting beside it: the panel sets `--ds-agent-rail-collapse-top` to the chat column's top padding plus the header row's block padding, which lands the 40px chevron on the same baseline and the same footprint as the CircularButtons opposite it. All three slots are visual staging: switching a thread, a tab or a pane moves the selection, never the transcript.

The panel is a floating card: `position: fixed` on the right at `--layout-chat-width` (420px at rest), inset `--layout-chat-inset` (20px) from the top, right and bottom edges, at `--radius-600` with the container border and `--shadow-floating`. Docked, a hover-revealed grip on the panel's left edge (the playground Chat view's handle, transplanted) drags the width up to 655px by rewriting `--layout-chat-width` inline on `<html>`, so the body inset and header offset slide with it; the chosen width holds for the session. The container surface is translucent, so the panel carries a `backdrop-filter: blur(24px)` that turns the page behind it into glass; fullscreen view drops the inset and radius for a true takeover and raises the blur to 40px, which is what keeps the transcript readable while the page's colours still show through. Opening and closing are their own pair: the card fades in with a 10px rise on `--motion-duration-slow` / `--motion-ease-entrance`, and closing mirrors it out on `--motion-duration-base` / `--motion-ease-standard` — the provider holds the panel mounted for `MOTION_EXIT_SYNC_MS` (with `pointer-events` off, so a dying panel swallows no clicks) while the exit plays. The entrance runs on mount, so the view toggle, which keeps the node, never replays it. The switch between the two geometries is a glide, not a swap: position, size, radius, border colour and blur transition together on `--motion-duration-slow` with `--motion-ease-emphasized` (the border fades to transparent rather than dropping, so its width never snaps mid-glide), the scrim animates in when fullscreen exits below the dock threshold, and the widen grip suspends the width transition while dragging. At 1440px and above the panel docks: `html[data-chat="docked"]` pads the body by width + inset, so every page slides over instead of being covered, and the fixed sticky header offsets by the same amount. Docked, the panel is a non-modal `complementary` region — no scrim, no focus trap. Below 1440px (and in fullscreen view) it is modal: `role="dialog"`, `--color-scrim` behind it, focus trapped, body scroll locked through the site's owner-keyed wrapper (`website/src/lib/scroll-lock.ts`) over the library lock's shared counter — the `pin` strategy from the Components intro, whose fixed-body treatment exists for exactly this panel's iOS Safari problem, and whose shared counter is what lets the panel and a library Dialog close in either order without unlocking the page under the survivor — Escape closes. On phone widths (under 720px) it is not an overlay at all: the panel leaves the fixed layer for normal flow at `100dvh`, radius and inset dropped, and `html[data-chat="takeover"]` hides the page around it (the ambient background stays, since it is what the glass shows), so the chat *is* the document. That is what lets the soft keyboard be Safari's problem rather than the panel's — it scrolls the document to show the focused composer, as it does for any page with a field at the bottom, with no viewport arithmetic on our side. A viewport-sized `position: fixed` panel is exactly what iOS 26.0 Safari renders short under the keyboard, and every number it reports about that state is suspect, so no fixed geometry is trusted there. Hiding the page collapses the document, so the mount remembers the scroll offset and restores it, instantly, when the page comes back; the body lock is not needed on phones, because there is no page behind the panel to hold still. And there it is the takeover in state as well as pixels: the chat opens in fullscreen view and the expand toggle is hidden, since the panel has no card form to return to and a control that switches full screen for full screen is just chrome. Only the compact insets stay, because those follow the host's width rather than the view. On short viewports (under 620px tall) the vertical insets go too — that height belongs to the widget — and inside it the welcome dressing yields in order: the greeting steps aside first, then the starter pills, so the empty state degrades to header, centred composer and disclaimer rather than letting the greeting paint over the header. The thresholds are container queries on the widget, so the same guard covers the docked panel, the takeover and the playground's chat stage. The composer never gives up height in a squeeze. On a phone the welcome screen is a different layout, not a squeezed one: the soft keyboard takes the lower half of the screen the moment the composer is focused, and on the centred layout that leaves the starters pinned between the input and the keys. So the phone welcome (`phone` on `SiteChat`, set by the mount at the takeover width and by the playground for its mobile bezel and for a real phone) stacks — greeting centred in the empty thread, the starter pills directly above the composer, the composer pinned to the bottom from the first paint — and the first message glides nothing, because the composer is already where it ends up. The starters render before the composer in the DOM there, so the tab order follows the visual order. Its height guards are lower, since the stack needs less room: the greeting yields under 500px, the starters under 380px, which keeps the chips in reach with the keyboard up on most phones. The conversation column caps at 768px like the playground's chat stage.

The entry point is an `AiButton` floating at the bottom-right corner (`--layout-chat-inset` off both edges) on its own solid `--color-ai-button-bg` field plus the site's `--shadow-floating` elevation, with `aria-expanded` and `aria-controls`; it hides while the panel is open and takes focus back when it closes. Mounted from the root layout, it never remounts on navigation. The panel never renders on the chromeless routes (`CHROMELESS_ROUTES` in `website/src/config/chromeless.ts` is the authoritative list); /playground hosts its own copy on an injectable transport, with the provider wrapped around the whole layout so its chat director and the stage share one conversation. The welcome starters follow the page — a new chat on /components proposes component questions, one on a blueprint page proposes system questions (`SiteChat/starters.ts`, longest-prefix match on the pathname). Each route holds a pool of pre-written questions and a seeded draw shows three: the seed lives in the provider, rolled once per page load and again on New chat, so a refresh or a fresh conversation shows a different mix while re-renders and client-side navigation never reshuffle the chips under the visitor — pre-written on purpose, the variety is theatre rather than tokens. The first time a page's set shows, it stages PromptSuggestions' pending shimmer for `MOTION_SUGGESTIONS_THINK_MS` before the chips pop in — theatre, since the labels are pre-written, but the same beat the follow-ups play for real — and the reveal memory lives in the provider, so closing and reopening the panel shows the set standing rather than generating again; navigating to a page whose set has not yet revealed replays the show, and so does New chat, which re-rolls the seed and reveals the fresh draw. The disclaimer line is set in `--font-caption-*` on `--color-text-tertiary`, the one place the site uses the floor of the type scale, because it is text a visitor can find but never has to read to use the chat. It links to /privacy, which carries the AI-use and 30-day logging disclosure.

The entry point also carries the page's TLDR: the mount passes `AiButton` a per-route `summary` from the page-summaries registry (`website/src/data/page-summaries.json` — hand-written for the static routes, derived for component pages), so hovering the button summons the panel specced in the AiButton section with that page's pre-written summary. Its chips open the chat mid-answer: the mount resolves the tapped chip's `prompt` and hands it to the provider's `send`, so the panel is a shortcut into the same conversation, not a second chat. The site palette makes the same move from the search side: whatever the visitor has typed appears as a final ask-the-assistant row ("Ask" plus ASSISTANT_NAME from `scripts/brand.mjs`) whose label is the query itself, and selecting it opens the panel and hands the query to the same `send` — so ⌘K is a way into the conversation too, and a search that matches nothing ends in an answer path rather than an empty state. The row's `trailing` node is the AI-ring chip wearing the FAB's own face (the `forum` glyph plus the same "Ask" label) — solid `--color-ai-button-bg` field and all, so the row's hover highlight never shows through the pill — the AI family's sanctioned passive mark inside list chrome: decorative and `aria-hidden` (the group heading already says it, and a real button inside a `role="option"` row would nest interactive controls), replacing the row's leading icon rather than repeating it, and dropping its words for the icon alone under 480px. Deliberately not auto-revealed — an uninvited panel is an interruption; `summaryPinned` waits if that call ever changes. One composition constraint worth its sentence: the FAB adds no `backdrop-filter` of its own — the button's fill is opaque, and an ancestor backdrop root would blind the panel's own frost. In the chat itself, the composer pins the same registry's knowledge as a context note — "Looking at Page name", resolved as the summary title first and the nav label as fallback, with no chip on a route neither knows. `contextLabel` overrides that outright, `null` included: a staged product's chat (the payroll console template) is not reading one of this site's pages, and a mock assistant naming the template it is demonstrating breaks the fiction.

Answers link into the site: the persona asks for internal markdown links, and the widget's markdown renderer turns a same-site path into a client-side navigation, so a docked panel stays open (and the conversation running) while the page changes beside it. When the panel covers the page (fullscreen view, or any viewport below the dock threshold) following a link closes the chat first. Model output is untrusted, so only hrefs matching the slug-charset path shape become links; anything else renders as plain text. Each committed assistant turn carries a message-actions row in `ChatMessage`'s actions slot, pinned permanently visible with `showActions`: copy (the raw markdown; the icon crossfades to a `--color-status-positive-border` tick on `--motion-duration-fast`, reverting after a beat, with an `aria-live` announcement) and thumbs up/down. A thumb posts the exchange's log id and the verdict to the feedback endpoint, renders optimistically, and shows filled at full icon strength while chosen; the thumbs only render when the server logged the exchange, so the playground's sim transport shows copy alone.

Under that row, in `ChatMessage`'s footer slot, sit the turn's follow-up questions: the same `PromptSuggestions` row the welcome screen uses for its starters, at the same scale and layout, because both answer "here is something you could ask" and a second pattern for the same job would be one too many. They arrive a beat after the answer commits — a small model writes them from the finished question-and-answer pair — and while the request is in flight the row holds their place with PromptSuggestions' pending shimmer, shown only under the newest settled turn (a shimmer beneath an old answer would pull the eye backwards): a real loading state, unlike the welcome starters' staged one — and one that never resolves to nothing: a request that comes back empty substitutes the written fallback pool in `lib/chat-followups.ts` (rotated by turn, skipping the question just asked), because a shimmer that dissolves into blank space reads as a failure. The row fades up on `--motion-duration-slow` with `--motion-ease-entrance` rather than appearing, and tapping a chip sends it as the next message. Every suggestion the chat shows — starter, follow-up, or the FAB summary panel's chips — is held to one length budget (`SUGGESTION_MAX_CHARS`): a Chip never wraps, so a label past the message column runs off the edge of the docked panel. One that will not fit is dropped rather than clipped, which is why the row can come back with two chips — and when a generated set comes back with none, the written fallbacks stand in rather than an empty row, since the pending shimmer has already promised chips.

### Site header (pattern)

The header is website chrome, not a library component: `MegaNav`, mounted once from the root layout and skipped on the chromeless routes, like the footer and the chat. It is two bars sharing one markup pattern: the in-flow header at the top of every page, and a `position: fixed` sticky overlay that slides in on `--motion-duration-slow` / `--motion-ease-standard` once the in-flow bar has scrolled out, so nothing in the page shifts. Both carry the brand, the primary links, the Design system mega panel, the input-dressed search button that opens the site palette (⌘K's visible twin, labelled with the palette's own "Search or ask anything" placeholder), the brand switcher and the theme toggle. The bar folds twice as it narrows: below 1536px the search pill collapses to its icon circle (the full dressing needs about 1510px of viewport, measured), and below 1280px the pills, switcher and toggle hand over to the hamburger drawer rather than clipping. When the chat docks, the sticky bar's padding grows by the panel's width so its nav items stay over the shifted page. The links sit left-aligned beside the brand — a `--primitive-gap-1000` margin plus the first pill's own padding sets the gap — rather than centred on the page, and the wordmark carries the system's one weight-700 setting so the name reads a step heavier than the pills. The current section's pill takes the teal selection convention — `--color-action-primary-bg` fill with `--color-action-primary-text`, hovering to `--color-action-primary-bg-hover`, the same treatment as SegmentedControl's active segment — because the earlier neutral fill was the hover colour too, and vanished into the bars' glassy backdrops. Desktop pills only: the drawer — the sole nav for everything at 1279px and under — keeps NavList's own selected styling, and its floor row carries the brand switcher beside the theme toggle.

**The sticky bar's backdrop is a progressive blur, not a single sheet of glass.** A `backdrop-filter` is uniform across the box it sits on, so a tint gradient fading to transparent over one 20px blur still ends in a hard line where the blur stops, which is the glass-edge look the first version had. A `mask-image` on the same element multiplies both the tint and the filtered backdrop by its alpha, and that is what lets the blur itself dissolve. So the bar carries a `.stickyBackdrop` stack of four absolutely positioned layers reaching `--sticky-backdrop-reach` (64px) below the bar: blur radii rising 2, 6, 12, 24px from the bottom layer to the top, each masked over a band that shrinks as the blur grows (the 2px layer fades across the whole reach; the 24px one is gone by 40% of it), so the page is sharp at the foot of the veil and fully frosted behind the links. The tint rides on the bottom layer alone, `--color-bg-page-primary` at the top stepping through `color-mix` to transparent, in both themes: dark's page surface is the near-black floor, so the bar reads as one with the viewport edge rather than as a lighter panel over the page. The mask keywords are the one sanctioned off-token colour in the file.

The stack is child layers at `z-index: -1` inside the bar's own stacking context, never a filter on the bar itself: a backdrop-filter on the bar would make it a backdrop root, and the mega panel inside it (its own `blur(32px)` glass over an 88% container surface, denser than the chat's because it lands on display type) would then sample only the bar's subtree instead of the page, and its blur would silently die. The phone drawer keeps a single uniform `blur(24px)`, since a full-height sheet has no edge to dissolve.

### Site footer (pattern)

The sitemap footer is website chrome, not a library component: `SiteFooter`, mounted once from the root layout inside a pathname gate (`SiteFooterMount`), so every page carries it without a per-page import. It skips the chromeless routes (`website/src/config/chromeless.ts`), the same set the chat panel denies.

A `FadeDivider` on top — the shared 1px hairline that fades to `--color-bg-container-primary-transparent` at both ends, the same line the home hero and the DS landing draw — then a brand block beside four columns holding five link groups under a `<nav aria-label="Footer">` (Site, Design system, Docs, Foundations, Elsewhere), then a quiet copyright row. Four groups derive from the nav config (`getSectionItems`, `docsSidebarLinks`, `foundationsSidebarLinks`) and `website/src/config/social.ts`. The Site group is hand-listed in `SiteFooter.tsx`, because the top-level nav links live in MegaNav's own markup rather than in the nav config — the one duplication in the footer. Group names sit in `--font-title-body-weight` at `--font-paragraph-sm-size` on `--color-text-primary`; links are `--font-paragraph-sm-*` on `--color-text-tertiary`, hovering to `--color-text-primary` on `--motion-duration-base`. External links carry a trailing `open_in_new` at `--icon-size-500`. The brand block holds the same brand mark and wordmark the header wears, at the same 24px — with one deliberate divergence: the header wordmark runs the system's lone weight 700 (the Site header pattern above), while the footer's keeps `--font-title-body-*`, and it is opacity that sets the footer's back (0.5, full on hover and focus), not a smaller or greyer variant — plus the monochrome social icon links (36px targets, `currentColor` at `--color-text-tertiary`); the copyright sits alone in the bottom row, left-clustered so the fixed chat button never covers it, with extra bottom clearance on phones for the same reason.

**The brand block is what makes the columns line up, and that is its structural job as much as its brand one.** It is exactly `--layout-sidebar-width` wide, separated from the links by `--layout-column-gap` — the docs shell's own rail and gutter — so the link grid begins on the same vertical line the page's content does. On a docs page its four columns land pixel-for-pixel on the four content cards above them; on a page with no rail it reads as an ordinary brand block with the columns starting after it. Five equal columns stretched across the whole shell was the version that never sat right: the pages divide that same width differently (three-up on the home page, four-up on the docs pages), so the footer's column edges landed a few pixels off theirs — 2px off the last card, 30–50px off the middle ones, and near-misses read as mistakes where a clear offset reads as a decision. Anchoring the grid to the content line means it either matches exactly or is offset by a whole rail, and the footer stays **identical on every page**: nothing in it reacts to which shell a page uses.

Five groups fit four columns because Site and Design system share the first one — the pairing that keeps the four columns closest to even. (A link flagged `desktopOnly` in the nav config hides below 960px, so a column's length can differ by viewport; the footer still reacts to nothing page-specific.) Below 960px the rail is gone from the pages too, so the brand block stops reserving its width and sits above the links, the stacked pair unstacks (`display: contents`) and all five groups flow as columns — 3 + 2 there, 2 + 2 + 1 on phones. As a body flex child the footer inherits the docked-chat inset and pins to the viewport bottom on short pages via `margin-top: auto`. Focus rings are the sanctioned `--color-action-primary-bg` outline; teal appears nowhere else in the footer.

---

## Light / Dark Theme Contract

The theme is activated by `data-theme="dark"` on the HTML root element. The `tokens-light.css` applies under `[data-theme="light"], :root`. The `tokens-dark.css` applies under `[data-theme="dark"]`.

**Tokens that invert:**
- `--color-bg-page-primary` #F1F1F1 → #050505
- `--color-bg-container-*` (white and light grays → dark grays)
- `--color-text-primary` #050505 → #F1F1F1
- `--color-text-secondary`, `--color-text-tertiary` (dark grays → light grays)
- `--color-divider` (light semi → dark semi)
- `--color-input-bg-primary`, `--color-input-border-primary`, `--color-input-text-primary`
- `--color-action-passive-*` (near-transparent fills swap to dark semi-transparent)
- `--color-bg-container-inverse` / `--color-text-on-inverse` (the inverse surface — ImageCompare's handle label, the "today" markers — flips from near-black to near-white)
- `--color-scrim` (0.5 → 0.7 black); `--color-control-thumb` chains to the on-action ink in both themes, so its per-theme value follows the action plan
- `--shadow-floating`, `--shadow-modal` (shadow opacity increases in dark mode)

- `--color-action-primary-*` — the action family inverts by design: the light theme's deep fill (#0E6E8F) under a light label becomes a light fill (#3CA5C6) under a deep label, because one teal step cannot clear contrast against both page floors (see Action / Brand)

**Tokens that stay stable:**
- Status border colors (`--color-status-*-border`) — same in both themes
- Status background/text invert to their dark counterparts for contrast

**Rule:** Never hardcode `color` or `background-color` with a hex value in a component. Always use a semantic token — the theme swap is the only mechanism for dark mode, no manual `prefers-color-scheme` queries in components. One sanctioned exception: ColorPicker's colour-mixing constants (the white/black overlay gradients, the hue spectrum, the picker-handle white) are colour-space physics no theme token can represent — they are written as `hsl()` literals, commented in place, and documented in the ColorPicker spec.

---

## Do's and Don'ts

### Do
- Use semantic tokens (every `CATEGORY_PREFIXES` category — `--color-*`, `--radius-*`, `--gap-*`, `--padding-*`, `--border-*`, `--font-*`, `--motion-*`, `--icon-size-*`, `--shadow-*`) in every component. Never use `--primitive-*` tokens directly.
- Reserve `--color-action-primary-bg` (teal) for primary CTA buttons, focus rings, and active input states. The one other sanctioned use is as the lead series colour in charts; nowhere else.
- Use Nunito Sans weight 300 for display/marketing text and weight 600 for in-app headings. The weight split is intentional.
- Apply `--radius-pill` to all buttons, `--radius-300` to all inputs and standard containers, and `--radius-600` to Card/EntityCard navigation tiles. This contrast is the system's shape signature.
- Map all feedback UI to the five-variant status system (`info`/`positive`/`warning`/`error`/`neutral`) — Badge, Alert, Toast, ProgressBar all share the same semantic tokens.
- Prefer `--color-divider` for rule lines over custom border colors.
- Use Material Symbols Rounded for icons, sized with `--icon-size` set to a scale step (`sm` 20 / `md` 24 / `lg` 32 / `xl` 48). Never set `font-size` on an icon directly.
- Always wrap your app in `ToastProvider` before calling `useToast()`.

### Don't
- Don't use primitive tokens (`--primitive-neutral-05`, `--primitive-teal-07`, etc.) in components or page styles.
- Don't write a `prefers-reduced-motion` query in component CSS — the duration tokens already collapse under it. The one exception runs the other way: that guard is CSS and cannot see a JS-driven loop or timer, so a JS-driven surface has to check the query itself (ShaderField's render loop and Carousel's autoplay are the worked examples) — or ride a library default that does, as the recharts charts do through `isAnimationActive: 'auto'` (the chart-set spec owns that contract).
- Don't use teal decoratively — it is the primary action color. Using it on text or illustrations dilutes its CTA signal. (Chart series are the one sanctioned exception: teal leads the default palette.)
- Don't set `display` size typography (Mega/Display tokens) inside app UI pages — reserve them for the marketing homepage hero only.
- Don't bold display sizes. Weight 300 is non-negotiable for Mega/Display; weight 600 is the max for in-app headings.
- Don't invent a fourth surface tone outside page / container-primary / container-secondary / container-tertiary. The neutral ramp is intentionally short.
- Don't add `box-shadow` to standard cards and containers. Depth comes from background steps, not shadows. The one exception: interactive Card tiles lift with `0 8px 24px rgba(0,0,0,0.3)` on hover — a deliberate navigational affordance, not an elevation pattern to copy elsewhere. Floating surfaces and modals use `--shadow-floating`/`--shadow-modal`.
- Don't hardcode colors. Every color must be a semantic token so dark mode works without extra code.
- Don't break the five-status system by adding a sixth variant (e.g. "brand") to Badge or Alert. Use `neutral` and customize within the component page if needed.
- Don't disable the `data-theme` swap on any subtree — all components must participate in theme switching.

---

## Responsive Behavior

### Breakpoints
The canonical set is **1279 / 1151 / 959 / 768 / 600**, all `max-width`. The rail widths and column gap live as `--layout-*` custom properties in `globals.css`, so the mid-breakpoint narrowing applies to every page at once:
- **≥ 1280px** — full shell: 291px left nav (`--layout-sidebar-width`), 320px right rail (`--layout-rail-width`), 60px column gaps (`--layout-column-gap`).
- **≤ 1279px (mid)** — both rails narrow (nav 240px, rail 280px) and the column gap tightens to 40px, keeping the center column at a readable measure.
- **≤ 1151px** — the right details rail stacks below the main content (per-page media query on the `resumeLayout`/`updatesLayout` flex row); the left nav stays.
- **≤ 959px (tablet)** — the left nav hides (its links move into the header drawer's section accordions) and pages collapse to a single column.
- **≤ 768px (mobile)** — **the token layer's only breakpoint**: display-tier typography and section-rhythm spacing collapse (see Typography → Hierarchy and Spacing → Gap Scale).
- **≤ 600px (phone)** — body side gutters tighten; dense grids switch to horizontal scroll inside their own blocks.

A section-specific threshold outside this set is allowed only when it is content-driven (a bespoke grid that breaks at its own natural width) and commented in place; everything else uses the canonical five.

The thresholds themselves stay raw numbers in the media queries, and that is settled rather than pending: CSS custom properties cannot drive `@media` conditions, and a preprocessor dependency is not worth it for five documented literals. This section is their single authoritative home — the rail widths and gutters they switch are tokenized (`--layout-*`), the numbers that trigger the switch are not.

### Typography Collapse
- Handled by the token layer at ≤768px — Display 2 64px → 40px, Sub Display 30px → 24px, Mega/Display 1 proportional. No per-page overrides.
- In-app headings (H1–H3, 30–22px) hold at every viewport; never reduce H3 below 20px.

### Layout Collapse
- `AppLayout` sidebar collapses to off-canvas/drawer on mobile.
- Component index grids reflow from 3-up (desktop) → 2-up (tablet) → 1-up (mobile).
- `SelectionCard` groups reflow from side-by-side to stacked on narrow viewports.
- Table components allow horizontal scroll on mobile rather than reflowing columns.

### Touch Targets
- All interactive elements minimum 44 × 44px effective touch area (WCAG 2.5.5).
- Button default height: ~36px from padding + line-height. Add `min-height: 44px` if used as standalone tappable button on mobile.

---

## Iteration Guide

1. **Adding a new component variant** — Add a modifier class (e.g. `.ds-badge--brand`) and reference only `--color-*` semantic tokens. Add corresponding light and dark values to `tokens-light.css` and `tokens-dark.css` if a new semantic meaning is needed.
2. **Adding a new status** — Add `--color-status-{name}-bg`, `--color-status-{name}-border`, `--color-status-{name}-text` to both theme files. Then add the variant to Badge, Alert, Toast, and any other status-bearing components.
3. **Adding a new spacing step** — Add to `tokens-primitives.css` first, then alias in `tokens-light.css` only: spacing is not theme-split, and `tokens-dark.css` carries nothing but colour and shadow.
4. **New typography style** — Add to `tokens-typography.css` following the `--font-{name}-{property}` naming pattern.
5. **Reference a component token** — Always use the full CSS variable, e.g. `var(--font-heading-1-size)`. Never inline the resolved value.
6. **Storybook stories** — Each component must have a `.stories.tsx` file with a Default story and one story per meaningful variant. Use `data-theme` Storybook theme toggle to verify dark mode.
7. **Before shipping** — Run `npm run verify`, the full local quality gate — CLAUDE.md's CI & Local Verify section owns the step list; every Storybook story renders in headless Chromium as a smoke test, with any story `play` function asserting behavior on top. The same checks run automatically in CI (`.github/workflows/ci.yml`) on every push and PR, including a drift guard that fails if generated documentation is stale.

---

## Known Gaps

- **Figma parity** — The system originates in Figma ([robr0-ds26](https://www.figma.com/design/8NzqDS8iRsBTFPbNGj3Woj/robr0-ds26)), and foundation/component pages carry `figmaUrl` deep links to specific frames — currently hidden site-wide behind `SHOW_FIGMA_LINKS` in `website/src/config/social.ts`, because the file lags the coded system. Keeping the Figma file and the coded tokens in sync is still a manual process — there is no automated export pipeline.

Three former entries left this list as decisions rather than work: JS-driven timings now share one home (`src/tokens/motion.ts` — see Motion → Migration status), the raw breakpoint literals are settled as raw (see Responsive Behavior → Breakpoints), and form-level validation is permanently the consumer's form layer, not the system's (see the Input spec).
