The design language in one file

Tokens, typography, colours, and every component spec in a downloadable markdown reference. The source of truth for how the system looks and behaves, copied verbatim from the repo on every build.

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)

Variantbg (light)bordertext (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.

TokenLightDark
--color-ai-gradient-startred-05 #F37F9Bred-05 #F37F9B
--color-ai-gradient-midblue-05 #5475D4blue-05 #5475D4
--color-ai-gradient-endteal-07 #118AB2teal-07 #118AB2
--color-ai-button-bgneutral-00 #FFFFFFtrue-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 tokenResolves toFeeds
--font-family-headingvar(--font-family-primary)The display and heading tiers — Mega 1/2, Display 1/2, Sub Display, Heading 1–3
--font-family-bodyvar(--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 prefixSize≤768pxWeightLine HeightLetter SpacingUse
--font-mega-1-*132px64px3000.92+2%Marketing hero — never in app UI
--font-mega-2-*116px56px3000.92+2%Marketing hero — never in app UI
--font-display-1-*96px48px3001.05+2%Large marketing section heads
--font-display-2-*64px40px3001.1 (1.15 ≤768px)+1.5%Marketing section heads
--font-sub-display-*30px24px30044px (36px ≤768px)+1.5%Sub-section heads on marketing pages
--font-heading-1-*30px—60044px+1.5%Page-level h1 in app/docs
--font-heading-2-*26px—60032px+1.5%Section headings in app/docs
--font-heading-3-*22px—60028px+1.5%Sub-section headings in app/docs
--font-title-body-*16px—60024px−1%Card titles, table column heads, bold labels
--font-paragraph-emphasis-*16px—50024px−1%Emphasized body, button labels, input labels
--font-paragraph-*16px—40024px0Default running text
--font-paragraph-sm-emphasis-*14px—50020px0Compact button labels, badge text, form helpers (emphasized)
--font-paragraph-sm-*14px—40020px0Secondary body, helper text
--font-overline-*14px—60020px+8%Uppercase labels — eyebrows, map callout titles, code-language chips
--font-caption-*12px—40016px0Footnotes 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

LevelTokenSizeWeightRole
Page title (h1)--font-display-2-*64px300Route title — rendered once per page at the top, outside the markdown body
Major section (h2)--font-sub-display-*30px300Top-level content sections (e.g. Colors, Typography)
Sub-section (h3)--font-heading-3-*22px600Named groupings within a section (e.g. Token Tiers, Action / Brand)
Minor heading (h4)--font-title-body-*16px600Inline 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:

/* 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

TokenValueTypical Use
--gap-0502pxTightest inline spacing (icon + label micro-gap)
--gap-1004pxCompact component internals
--gap-2008pxStandard icon-to-label gap, button icon spacing
--gap-30012pxForm field stack spacing
--gap-40016pxCard internal element spacing
--gap-50020pxSection element spacing
--gap-100040pxCard-to-card spacing, major internal gaps
--gap-150060px (40px ≤768px)Between component groups
--gap-200080px (60px ≤768px)Between major page sections
--gap-3000120px (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

TokenValueTypical Use
--padding-0502pxBadge vertical padding
--padding-1004pxMicro insets
--padding-1506pxCompact button/input vertical
--padding-2008pxStandard button vertical, badge horizontal
--padding-30012pxCompact button horizontal, compact input horizontal
--padding-40016pxStandard input horizontal, card inner padding baseline
--padding-50020pxStandard button horizontal
--padding-100040pxLarge card insets
--padding-150060px (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.

RoleTokenValueMeaning
Section break--gap-200080px (60px ≤768px)Separates page regions. The only gap between sections.
Heading bind--gap-100040px, flatTies a heading to the content it introduces.
Intro bind--gap-50020pxTies a tagline or lede to its body copy.
Group bind--gap-2008pxTies a label to the items it names.
Column gutters--layout-*presetsGutters 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:

RoleTokenValueMeaning
Card gutter--gap-50020pxBetween Panels, in every direction.
Card inset--padding-50020pxA Panel's own padding — the gutter and the inset match, so the grid reads as one fabric.
Region gap--gap-40016pxBetween a Panel's regions: header to chart, chart to legend row.
Cluster gap--gap-200 / --gap-3008 / 12pxWithin 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.

TokenValueUse
--radius-0502pxReserved — rare micro elements
--radius-1004pxBadges (ds-badge)
--radius-2008pxSmall sub-elements, inner nested surfaces
--radius-30012pxInputs, cards (standard), modals
--radius-40016pxLarge feature cards, hero containers
--radius-60024pxCard/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-120048pxPill containers, oversized decorative elements
--radius-pill999pxAll 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

TokenValueUse
--icon-size-50020pxCompact controls, inline affordances, chips, list chevrons
--icon-size-60024pxDefault — the size an icon is unless told otherwise
--icon-size-80032pxFeature icons — EntityCard, section headers
--icon-size-120048pxMarketing 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:

.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.

LevelTreatmentUse
Page floor--color-bg-page-primary — no border, no shadowBody background, app root
Container primary--color-bg-container-primary — no borderSidebars, card fills, section bands
Container secondary--color-bg-container-secondary — optional --color-bg-container-border borderNested cards, inner panels
Container tertiary--color-bg-container-tertiary — no shadowPressed/active states, deepest UI surfaces
Input--color-input-bg-primary with --color-input-border-primary hairlineText inputs, textareas, dropdowns
Status containersColored bg + colored border per variantAlerts, 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):

TokenValueUse
--motion-duration-fast150msQuick feedback: hovers, icons, tooltips, nav links
--motion-duration-base200msThe default — color/opacity/border transitions
--motion-duration-slow300msStructural change: accordion, sidebar width, toast enter
--motion-duration-slower600msDeliberate 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:

TokenValueUse
--motion-ease-standardeaseGeneral default for color/opacity transitions
--motion-ease-emphasizedcubic-bezier(0.4, 0, 0.2, 1)Size/layout changes (sidebar width, accordion height)
--motion-ease-entrancecubic-bezier(0.16, 1, 0.3, 1)Expressive decelerate for enter animations (modals, dropdowns, toasts)
--motion-ease-linearlinearContinuous motion (spinner, progress bars)
--motion-ease-springcubic-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.

VariantFillBorderText
primary--color-action-primary-bg (#0E6E8F light / #3CA5C6 dark)none--color-action-primary-text (#CFEAF3 light / #052F3E dark)
secondarytransparent--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
destructivetransparent--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), separators, labelled groups, 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, separators, labelled groups, 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), 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).