# design language

> Part of the documentation that comes with Beamline's complete system (docs/DESIGN-LANGUAGE.md in the kit). Your coding agent can do all of this for you through Beamline's MCP server: [connect it](https://beamline.io/connect).

The visual and behavioural rules every component follows. All values live in `ui/styles/tokens.css`. Components read
tokens and never hard-code a colour, radius, shadow or duration. Rulings marked **(owner)** are fixed; everything
else can be tuned in the token file.

## Character

A dense, quiet instrument panel for people who look at numbers all day: trading desks, analytics, CRMs, agent
consoles. Chrome recedes and data leads. One accent colour marks what can be acted on. Motion confirms that
something happened and never performs for its own sake.

## Roots

The system lives inside a `.pui` root: the whole document (`<html class="pui dark">`) for an app built on it, or a
`UIRoot` region inside any other app. Tokens, base styles and utilities apply only inside a root, and every name is
`pui`-prefixed, so a host app's own variables, Tailwind theme, resets and animations never change. Dark is the
default theme of a root; a nested root may switch theme or brand for its subtree.

## Foundation parameters (one place, everywhere)

The system is three layers (O1 §14): foundations → style (look) → component. A component reads every duration, radius,
font size and spacing step from tokens, and those tokens are **derived** from a handful of scope parameters, so one value
reaches every element, every overlay and JS animation tokens:

| parameter | token on the scope | UIRoot prop | derives |
|---|---|---|---|
| Animation speed | `--pui-motion-scale` (1) | `motionScale` | `--pui-motion-in/out`, `--pui-duration-*`, `--pui-spring-*`, `--pui-stagger-*`, loop durations; JS motion (`useMotionTokens(hostRef)`) resolves each timing at its host |
| Roundness | `--pui-radius-scale` (1) | `radiusScale` | `--pui-radius-*`, `--pui-bezel-radius` (a style's pinned corners excepted) |
| Type size | `--pui-type-scale` (1) | `typeScale` | `--pui-text-2xs … -5xl`, `--pui-text-reading`, `--pui-text-code` and every role class |
| Type contrast | `--pui-type-ratio` (1) | `typeRatio` | each step's distance from body size (step = base × (step/base)^ratio) |
| Spacing | `--pui-space-scale` (1) | `spaceScale` | `--pui-space-*` and the named paddings and gaps; control heights and row density stay |
| Motion mode, density, brand, focus | `data-motion`, `data-density`, `--pui-brand`, `--pui-focus-width/-style` | `motion`, `density`, `brand`, `tokens` | as before |

Precedence for every derived value: foundation default → the style's modifier (its radii are values the roundness
scales; `--pui-motion-look` is its share of the foundation speed) or a declared **pin** (an identity value the parameter
leaves alone, e.g. Blueprint's 2px corners, marked `--pui-pinned-<token>: 1`) → the customer's parameter → an explicit
token override (beats a pin). Reduced and none motion beat any speed (1ms; reduced keeps the fade tokens). The approved looks render pixel-identical at
the defaults. Text size utilities (`text-xs` …) carry no leading of their own; leading comes from the role or the
container. `.text-reading` (15px, 1.6) is for long-form surfaces only. The gallery's Foundations pages and System panel
edit these live; a crafted style is a `beamline-style/1` file (`ui/runtime/style-file.ts`) exported as a complete preset.

## Colour

- **One input.** `--pui-brand` (default oklch 0.58 0.19 258, a cool blue). Accent, accent-ink, accent-strong and
  accent-tint derive from it with relative colour syntax. `data-accent` presets (neutral, violet, green, amber,
  orange, rose) swap only `--pui-brand`.
- **The brand engine.** `--pui-brand-l-14 … -86` (seven lightness stops, chroma and hue kept) and `--pui-brand-c-0 … -22`
  (six chroma stops, lightness and hue kept) all derive from `--pui-brand`, so a rebrand is one token. Use them for
  brand-tinted fills and illustrations instead of inventing a colour. The 0.22 chroma stop is gamut-mapped at some
  hues; check it at yours.
- **Dark is the default**, light is opt-in: `class="light"` or `data-theme="light"` on `<html>`. Both themes
  define every token; components never branch on the theme.
- **Neutrals carry a trace of hue 260** so greys sit with the blue accent instead of looking dead.
- **Surfaces step by lightness:** page → canvas → surface → surface-2; `hover` and `hover-2` are one rung up;
  `inset` sits below (wells, code). `field` is the input fill.
- **Ink:** `ink` for primary text, `ink-2` for body and secondary, `ink-3` for meta (timestamps, captions, counts,
  placeholders). All three pass 4.5:1 on every surface and on hover, in both themes; the hierarchy comes from the
  steps between them, never from text too faint to read. Text on an accent fill uses `accent-foreground`, which picks
  near-white or near-black from the accent's lightness, so any brand colour stays readable.
- **Lines:** `line-soft`, `line`, `line-strong` are decorative hairlines. `edge-control` is the boundary of any
  input and passes 3:1 contrast against its background.
- **Semantic:** green, orange, red, blue, violet, each with a `-tint`. **(owner) Colour is never the only
  signal:** a status always has a glyph or a word as well (▲ +2.4%, "Failed", a check).
- **Trading:** `up` / `down` (+ tints) are separate from green/red so a product can flip the convention (red-up
  markets) without touching semantics.
- **Data series:** `series-1…8`, ordered for maximum contrast between neighbours; series-1 follows the accent and
  series 2–7 keep their hue distance from it (with the default brand they are the approved hues; a warm brand turns
  the palette with it instead of landing on amber). Series 8 is the neutral.
  With the look's default brand every series 1–7 keeps **3:1 against the card** in light and dark (WCAG non-text
  contrast; `scripts/e2e/series-hues-d5.mjs` checks it in every look). Pale colours go deeper on light paper instead:
  amber and green a step darker, Pop's lemon prints as gold, Ink alternates tints of 64 % or more with deep ink.
  A custom brand's own colour is the app owner's choice and is reported, not adjusted.
  Charts use series tokens, never semantic colours, unless the data is literally good/bad.
- **Consequence triples.** `--pui-consequence-blocking` ▲, `--pui-consequence-degrading` ■ (rounded), `--pui-consequence-hygiene`
  ○ (stroked), each with `-tint` and `-edge`. The token is the triple: colour + shape + word ("BLOCKING"). Using the
  colour alone is not using the token. Check with `filter: grayscale(1)`: if the meaning survives, it was used right.
- **Data ramps.** `--pui-ramp-heat-0…8` (sequential cold → hot), `--pui-ramp-cool-0…8` (one cool family) and
  `--pui-ramp-delta-0…8` (diverging, neutral pivot at stop 4); the continuous forms are `heat(t)`, `cool(t)`, `delta(t)`
  in `ui/lib/ramp.ts`. They are OKLCH so equal steps look equal, and they are encodings, not brand: a rebrand does not
  move them. Never build a ramp in HSL (its lightness is not perceived lightness).
- **(owner) Unmeasured = hatched.** Data that was never collected renders with `--pui-unmeasured-hatch` over
  `--pui-unmeasured`: never a zero, never the cold end of a colour ramp, never blank.

## Type

- **Reach for a role before a size.** Eleven role classes (plain CSS in `utilities.css`, so they work with or
  without Tailwind) bundle size, weight, leading and tracking; colour stays with the ink tokens:
  `.text-display` Geist 40 (one per screen) · `.text-title` Geist 30 (page titles) · `.text-heading` Geist 20
  (sections) · `.text-subheading` Inter 16 (cards, dialogs) · `.text-body` Inter 14 · `.text-body-sm` Inter 13 ·
  `.text-label` Inter 13/500 · `.text-caption` Inter 12 · `.text-overline` Inter 11 caps · `.text-numeric`
  Geist Mono 13 tabular · `.text-code` Geist Mono 12.5.
- Inter for interface text, Geist for display numbers and headings, Geist Mono for code and aligned figures.
- Scale (rem): 2xs .6875 · xs .75 · sm .8125 · base .875 · lg 1 · xl 1.25 · 2xl 1.5 · 3xl 1.875 · 4xl 2.5 · 5xl 3.5.
  Tailwind's `text-*` utilities resolve to these same tokens.
  **Product body text is 13–14px**; 15px only in reading surfaces (answers, docs).
- Every number that can change or sits in a column uses tabular figures (the base layer applies this to
  `[data-numeric]`, tables, and mono).
- Headings and display figures: weight 500, tracking −0.01 to −0.02em. No bold 700 in chrome. Weights are
  `--pui-weight-regular/medium/semibold` (400/500/600).
- Uppercase labels (`.eyebrow`) open up to +0.06em; mono caps +0.04em.
- **Wrapping:** `balance` on headings, `pretty` on body. Never `balance` on prose (it knows nothing about sentences
  and is capped at six lines). Dense table cells are fragments: ellipsis plus the full value in a tooltip.
- **Reading measure:** 45–78 real characters. `ch` is the width of "0", about 1.4× an average letter in Inter and
  Geist, so "66ch" renders ~95 characters. `--pui-measure` is 48ch (≈ 68 characters); re-measure if the face changes.

## Density and spacing

- 4px base: `--space-N` = N × 4px, from `--pui-space-0-5` (2px) to `--pui-space-32` (128px), identical to Tailwind's spacing,
  so `p-4` and `var(--pui-space-4)` are the same 16px. Semantic names for the values containers already use:
  `--pui-gap-inline` 8, `--pui-gap-stack` 12, `--pui-pad-popover` 16, `--pui-pad-control` 16, `--pui-pad-card` 20, `--pui-pad-panel` 24,
  `--pui-gap-section` 40, `--pui-gutter-page` 40. Controls: sm 32, md 36, lg 40px. Icons: `--pui-icon-sm` 14 in dense rows,
  `--pui-icon-md` 16 by default, `--pui-icon-lg` 20 in large controls. Tables default to 36px rows; `density`
  compact = 28, comfortable = 44.
- Gaps inside a control 6–8px, between controls 8–12px, between groups 16–24px, between page sections 32–48px.
- Panels in a dashboard sit on 12–16px gutters. Trading surfaces go to 8px gutters and 28px rows.

## Radii

chip 6 · control 9 · card 12 · panel 14 · window 16 · surface 18 · pill. Nested radius = outer − padding. Data
cells, chart plots and table frames stay square or 6px or less; round shapes are for things you press.

## Elevation

Lightness first, shadow second. Every shadow stack begins with a 1px ring so edges read on any ground:
`shadow-hairline` (flat rows), `shadow-btn` (controls), `shadow-card` (resting cards), `shadow-raised`
(hover or lifted), `shadow-overlay` (menus, popovers, dialogs). Dark theme shadows are heavier and the ring is
white at 8–12%. Overlays also get a scrim (`--pui-scrim`) and, for modal layers, a 2px backdrop blur
(`--pui-blur-overlay`). The double bezel (`--bezel-*`) is for the one or two frames a screen leads with.

## Looks

Every element has a **core** and a **look**. The core is the element itself: size, layout, states, keyboard, motion,
data. The look is the whole style on top of it: the fonts and their weights, icon stroke, corner radii, the page behind
everything (its colour and pattern), the highlighted word, and the paint of every surface. It is chosen like the theme:
once for the app (`data-look` on the `.pui` root, or `UIRoot look`), for any part of it (`data-look` on a wrapper), or
for one element (its `look` prop where it has one). The nearest choice wins, and switching the root's look restyles the
whole product at once. Theme and look are independent: every look exists in dark and light.

A look is one block of custom properties in `ui/styles/looks.css`: type tokens (`--pui-font-heading`,
`--pui-weight-title`, `--pui-heading-stretch`, `--pui-icon-stroke`), the radius scale, page and surface tokens
(`--pui-page`, `--pui-page-pattern`), the mark (`--pui-mark-*`) and the paint of each role (`--m-*`). Components never
test which look is on; they read the tokens.

Surfaces come in nine **roles**, and every painted part uses one of them and nothing else:

| role | class | used by |
|---|---|---|
| control | `m-control` | secondary buttons, triggers, chips, keycaps, the raised thumb of a segmented control or tabs |
| solid | `m-solid` + `--m-color` | primary and accent buttons, a selected day, anything filled with one colour |
| field | `m-field` | inputs, text areas, search, the select and date triggers; owns hover and the focus ring, and its 1px ring keeps the 3:1 boundary in every look |
| inset | `m-inset` | the well a thumb or selection sits in: segmented, tabs and toggle-group tracks, slider and progress tracks |
| toggle | `m-toggle` + `--m-color` | a well that fills as a solid when on (`data-state="checked"`): switch track, checkbox, radio; the element may set its off fill (`--m-toggle-off`) and a boundary ring (`--m-toggle-ring`) |
| knob | `m-knob` | the round thumb of a switch, slider or scrubber: always light, finished by the look (machined in metal) |
| tag | `m-tag` | small labels that sit on content: badges, category tiles, the highlighted word; a hairline by default, an ink outline in pop |
| surface | `m-surface` | cards, panels, metric cards, table frames |
| overlay | `m-overlay` | menus, popovers, select lists, dialogs, sheets, toasts |

A role class owns paint and paint states only (background, edge, inner light, shadow, hover, press, focus, disabled,
and the small hover and press offset a look may ask for); the element keeps layout. Colour comes from tokens; a solid
takes its fill from `--m-color` (accent, ink, red…), so one recipe lights any colour. To add a mark to a painted part (a
severity stripe, a selection ring), compose it with the role's own value, never replace it:
`shadow-[var(--m-surface-edge),inset_3px_0_0_var(--pui-red)]`.

A toggle that stays on in a toolbar (bold, alignment) sits pressed in: it takes the inset well's paint
(`--m-inset-shade`, `--m-inset-edge`) rather than a raised one, so on reads as held down in every look.

**The nine looks**

- `prism` (default) — cut crystal in a dark room (owner, 7 Oct 2026: "dark black … prismatic aurora at proper places
  … not uniform, not super prominent, still more into dark"; "light, not glow"; "make this prism style best out of
  all"). Clear crystal is seen only where light meets it, so a part is drawn by its cut edges, not by a fill: light
  from the top left catches a control's top and left edges as fine lines that fade along them, the outline of the cut
  is a faint hairline, the lower face sits in shade; fields are channels cut in, their lower lip catching the light;
  cards are slabs lit at the top-left edge over a deep shadow. Faces are nearly black and still; nothing is milky and
  nothing glows. The main action is white light itself (ink-black in light). Light splits only at the place of
  attention and inside the glass, and what it shows depends on the angle, like a real prism: in long runs of colour
  with gaps (never one even gradient) along the main action's bottom edge while pointed at (each button its own
  angle, turned by the pointer across the face, a new one at each press; a change of word or colour sweeps a band of
  it across once), along the bottom of the field being written in (lighting from the centre, drifting while focus
  stays), under the chosen tab (sweeping as it travels), and along a card's edge as the pointer arrives (a short flare,
  each its own shape; edge light). Otherwise
  colour lives in data (a natural spectrum whatever the brand) and in two far-off corners of the page; the bands of
  split light at chosen places are a part (LightBand). Cut, not moulded: crisp radii, a rigid quick motion, 1.5px
  icons, Geist throughout set tight, Geist Mono labels, the highlighted word in an Instrument Serif italic of white
  light. A brand or accent replaces the white main action. Light is a white room with the same cut glass.
- `soft` — the clean modern standard in the brand's air: the page and every surface carry a trace of the
  brand's hue (a share of its own chroma, so a neutral brand stays neutral), a soft light falls from the top of the
  page and a card's top edge catches it; hairline rims, short layered shadows, a faint sheen on solids, Geist over Inter.
- `blueprint` — the drafting table, for dense tools where depth is noise, with one set of rules everywhere: one 2px
  corner; one 1px line weight; a light rule for containers and a 3:1 rule for everything operable; registration ticks
  at the corners of surfaces and overlays only; no shading at rest (an overlay floats on one soft shadow); Geist with
  monospaced micro-labels, fine icons, a dashed focus ring, the highlighted word as a selected frame. Drawn as CAD:
  cyan wireframe lines and ticks on near-black (teal on white in light) over a quiet grid with points at its crossings.
- `glass` — a liquid lens. Clear glass is seen by its edges and by what it bends, not by a frosted fill: a surface is
  almost clear, with a thick rim (a bright line where light enters, top and left; a caustic glow along the bottom
  inner edge; a soft inner glow all round) and a light blur over what lies behind. The page behind is a faint drafting
  grid under two wide, heavily diffused, low-saturation pools of light in the brand's hue. Controls are small lenses of
  the same glass; overlays stay a denser pane so a menu reads over anything; corners generous, headings light.
- `metal` — one machined plate: the page is the metal (black anodised with diamond-cut edges in dark, anodised aluminium in light) with a
  visible bead-blasted grain and one broad reflection across it. Parts are milled into it, not laid on it: cards are
  pockets (a shadow under the top wall, the bottom wall catching light, the plate's grain running through), fields and
  wells are shallow pockets, and only actuators stand up like keys (buttons, thumbs, knobs) with a polished chamfer.
  Machined radii (no soft pills), anodised solids, a lathe-turned knob, segmented meters, wide Archivo headings,
  engraved mono labels, monospaced figures. Never chrome bands, brushed stripes or floating plates.
- `glow` — light in one place. A calm neutral night (clean paper in light) with hairline surfaces like soft; the light
  gathers where the eye should land and nowhere else: a faint haze of spectrum across the top of the page, the
  highlighted word written in that spectrum, and the main actions (primary and accent buttons) standing on a soft
  spectrum of light that spreads when pointed at. Cards, fields, charts, sliders and selected chips stay unlit.
- `pop` — a sticker sheet: a warm page with a dot grid (deep petrol in dark), ink outlines, a hard lip under every
  control that the element rides up on hover and sinks into on press, Gabarito headings over Figtree, bold icons, a
  tilted lemon label for the highlighted word, data in sticker colours, a warm default brand.
- `mono` — the printed instrument: a monochrome interface where only data carries colour, drawn as a dot matrix
  (solid marks as rows of short segments, soft fills as a grid of dots, meters as segments, lines solid); regions are
  cells framed by dashed rules with every inner divider dashed; the highlighted word underlined in dots. Actions,
  selection, links and focus are ink; the brand colour stays only on the data series it leads.
- `ink` — a pen-and-ink drawing. Lines are straight ink, never wobbled (a hand-drawn wobble read as a cheap sketch):
  a frame over each part's edge (`--m-pen*`) has a soft ink-on-paper edge, and on cards the ink varies along the line
  like pen pressure. Chart areas and meters are shaded by proper hatching, crisp 45-degree strokes from a seamless
  tile at full ink, never fading (`--pui-fill-mask`, `--pui-meter-mask`, `--pui-chart-fill-top/-bottom`). Solids are
  flat ink with a grain. On light paper one ink in the brand's hue (the sidebar printed solid, a faint off-register
  second impression); on black stock two inks, cream for type and lines and the brand as a spot colour. Instrument
  Serif for display sizes over a typewriter mono; the highlighted word a rubber stamp.

Charts follow the look through their palette and geometry, never through an effect on the marks: a mark is an
encoding, not a finish. A look may set its series palette after the brand (glass luminous, metal anodised, glow
emitted, pop sticker colours, ink tints of the ink), the line weight (`--pui-chart-stroke`), the fill strength
(`--pui-chart-fill`), a meter cut into segments (`--pui-meter-mask` on `.meter` tracks), a donut's arcs cut into
radial ticks (`--pui-arc-mask` on `data-marks-arc`, mono), and two finishes that are light or pattern rather than
paint (`ui/lib/look-filters.ts`): glow's tight halo of the mark's own colour in dark, and mono's dot grid on soft
area fills. A mark not in focus fades toward the card colour, never through transparency, which turns muddy on a
dark page. Bevels, speckle, outlines and stripes on marks were tried and rejected: they are stamped effects, and
stripes read as the hatch that means "not measured". The app shell's content area wears the look's page
(`look-page`), so the grid, aurora or dots reach real screens. Every pill reads the look's `--pui-radius-pill`
(`rounded-pill`; `rounded-full` is only for true circles: avatars, dots, spinners), so a machined or drafted look is
never undone by a soft pill, and an example's stage (`look-stage`) wears the look's page too.

Every look starts from a model of its material (how it meets light) and paints only what that model predicts;
an effect without a physical reason is not added. Shared means: a fine grain (`--m-grain`, `--m-grain-faint`) and a
static angled rim drawn with the edge light (`--m-rim`).

The references a look answers (a premium kit's volume, a glowing pill, sticker-style sites) give the genre only. A
look's colours, fonts and recipes are the system's own; nothing is taken from a reference.

Rules: a look is static at rest and moves only on hover, press or focus; text on every look keeps 4.5:1 and every
field and toggle boundary 3:1; disabled is the same in every look (reduced opacity, no light, no blur); a look never
changes the size scale (control heights, spacing, density), so switching it never re-flows a layout beyond text widths
following the look's fonts. Paint never animates by swapping a gradient (images cannot fade): hover and on/off move two
registered values, `--m-hover` and `--m-c`, and an element that wears a role lists `var(--m-transition)` in its
transition so hover arrives at once and leaves over `--pui-motion-out`.

**Not painted by a look, on purpose:** tooltips and value bubbles (an inverse label that must read the same over
anything), data marks (bars, cells, lines: an encoding, never a finish), table header bands and sticky cells (they must
stay opaque and flat under scrolled rows). Media frames
(video, image, a canvas) take only the look's edge, `shadow-[var(--m-surface-edge)]`, never its fill.

## Layers

One stacking ladder, read by every overlay component: `--pui-z-raised` 1 · `--pui-z-sticky` 10 · `--pui-z-overlay` 50 (modal
scrim) · `--pui-z-modal` 51 (dialog, sheet, lightbox) · `--pui-z-popover` 60 (menus, select, combobox, popover, hover card) ·
`--pui-z-toast` 70 · `--pui-z-tooltip` 80. So a menu or tooltip opened inside a dialog is always above it. Stacking inside
one component (a pinned column, a chart crosshair) stays local with small numbers.

## Texture

- `.grain`: a static noise tile (`--pui-grain-tile`), soft-light at 18% on dark / multiply at 6% on light, fixed over the page or clipped to one
  surface (`.grain--local` in a `.grain-host`). Never animated, never a hit target, gone in print.
- `.glass`: overlays only, static blur only (`--pui-blur-glass`); under `prefers-reduced-transparency` it becomes the
  opaque `surface-2` twin.

## Scrollbars

One scrollbar everywhere, drawn by `ui/styles/base.css` from `--pui-scrollbar-size`, `--pui-scrollbar-thumb`,
`--pui-scrollbar-thumb-hover` and `--pui-scrollbar-track`: a thin rounded thumb, no arrow buttons, no track fill, the same in
every theme and on every OS. Any app that imports the stylesheet gets it on every scroller, its own included.
Components never set `scrollbar-color` or `scrollbar-width: thin` (either one brings the OS bar back in Chromium);
a scroller that should show no bar uses `scrollbar-width: none` with `::-webkit-scrollbar { display: none }`, and
`ScrollArea` draws its overlay bar from the same tokens. `pnpm e2e` fails a route that draws any other bar.

## Motion (owner)

- **Arrive in 90ms, leave in 180ms, ease-in-out.** `--pui-motion-in`, `--pui-motion-out`, `--pui-ease-default`. Arrival and
  departure are different events and never share a duration.
- **The frequency law decides speed, not whether.** Hover arrives at 0ms and leaves over `--pui-motion-out`; a press
  is acknowledged at 0ms; the control's value, focus and aria state always change at once. What is touched a hundred
  times a day still moves (the owner's direction, 2026-10-05: every element morphs), but short (`--pui-duration-fast`)
  and never in the way of the next key: a menu or list highlight glides under the arrow keys, a typed character never
  animates, live market data never moves. `.ix` (utilities.css) encodes hover and press for any clickable thing.
- **Curves:** `--pui-ease-default` (the default), `--pui-ease-standard`, `--pui-ease-enter`, `--pui-ease-exit`, `--pui-ease-in-out`,
  `--pui-ease-out-strong`, `--pui-ease-drawer`, `--pui-ease-spring` (critically damped). `--pui-ease-exception` is situational: a
  reveal that should feel thrown into place, named as the exception where it is used. `--pui-ease-spring-bounce` and
  `--pui-ease-overshoot` overshoot and are not defaults.
- **Utilities** (`ui/styles/utilities.css`): `.disclosure-panel` animates to the content's height with no
  measuring; `.overlay-anim` gives native popovers and dialogs an enter and exit with no JavaScript; `.sr-fade` /
  `.sr-wipe` reveal by scroll position (at most one per screenful, never in the first, never fade-and-rise);
  `animate-enter-fade/-rise/-scale`, `animate-exit-*`, `animate-slide-in-*`, `animate-accordion-*`,
  `animate-shimmer`, `animate-pulse-soft`, `animate-marquee`. Stagger steps: `--pui-stagger-char/word/item/line`.
- Content changes (label swaps, numbers, morphing sizes) use the critically damped springs in
  `ui/lib/motion-tokens.ts` (`spring.snappy`, `spring.smooth`, `spring.morph`, all bounce 0).
- **No overshoot except dragged objects.** A sheet or slider thumb released by the finger may use `spring.held`
  (bounce .14); nothing that moves on its own bounces.
- **Focus is always visible and never animated:** a `--pui-focus-ring` outline at least 2px wide, 2px offset,
  `transition: none`. A look may draw it its own way (`--pui-focus-style`, `--pui-focus-width`: dashed in blueprint and
  mono, a 3px ink ring in pop, a double rule in ink), never thinner and never removed.
  `--pui-focus-ring` is the solid accent on dark and the brand at L 50 on light (one lightness cannot clear 3:1 on both).
  No component may remove it; components with custom focus styling must still show it on `:focus-visible`.
  Keyboard mode: an app that sets `data-kbd="on"` on the root after a Tab gets a louder amber ring with a halo
  (`--pui-focus-ring-kbd`).
- **Reduced motion is not a degraded mode.** Every duration token becomes 1ms (instant, but end events still fire),
  stagger becomes 0, and decorative loops stop entirely (tickers, marquees, shimmer). Nothing that carries
  information is removed: live data still updates and progress still shows. Fades stay, restrained (APPEARANCE A06):
  `--pui-fade-in`/`--pui-fade-out` equal the arrival and departure and keep their duration under `reduced` (and the
  OS preference under `auto`); `--pui-motion-travel` (1, 0 under reduced) scales the small offsets in the shared
  entrance keyframes, so `animate-enter-rise/scale` become fades. Dialogs, popovers, menus, the scrim, Button's
  label and loader and Skeleton's swap fade this way; nothing that moves starts moving. `none` removes the fades too.
- Price flashes: a 90ms tint in, 600ms fade out, direction also shown by glyph.
- **Matter (the morph grammar, 2026-10-04).** A selection travels instead of switching off here and on there: the
  segmented thumb, tab indicator, radio bead, sidebar and pagination plates, chosen calendar day and selected tree
  row are contours (`ui/lib/contour.ts`), four corners on critically damped springs with uneven response, edges that
  only bulge outward, a slight neck while stretched. Elasticity is the parts arriving at different moments, never an
  overshoot, so the no-bounce rule holds. The control commits at 0 ms; only the decorative surface settles, and a
  reflow places it without travel. At rest it is the plain CSS box with no clip and no frames.
- **Looks have physics.** Each look states how its surfaces move (`--m-morph-spread`, `-front`, `-waist`, `-weight`,
  `-rim`): soft is viscous, glass a little heavier, pop rubber, ink a brush; metal, blueprint and mono stay rigid.
- **Layers open out of their source.** A popper layer starts as its trigger's box and grows (the near side at once,
  so rows read in 90 ms; the far side by ~200 ms); a dialog starts as whatever opened it. Both close back into it.
- **Changes keep their size and their path.** A bar's lost part stays as a ghost and drains, a gained part arrives
  lit (`_shared/delta-ghost`); an action that changes several values sends a trace to each (`ui/lib/trace.ts`).
  Reduced motion keeps both as still marks. Live market rows never travel.
- **Every element morphs (2026-10-05).** Four shared mechanisms carry it: a **plate** that travels to the active row or
  cell (`_shared/glide.tsx`: menus, command menu, listboxes, select, time picker, watchlist selection, OTP focus ring,
  transcript word); **collections** whose items grow in, slide to new places and leave a fading ghost
  (`hooks/use-flip.ts`: chips, tags, filters, comments, attachments, breadcrumbs, table rows); **values** that roll or
  reshape (number-field steps through `_shared/roll.ts`, category-bar widths, sparkline paths, SlotText); and **mode
  swaps** that change size in place (inline edit, command-menu pages, password reveal). Playable on Foundations → Morph.

## Interaction feedback

- Press: 0.96–0.985 scale depending on width (smaller things press deeper), instant. `--pui-press-scale` (0.955) is the
  default for small controls using `.ix`.
- Hit targets are at least 24px (`--pui-hit-min`). `.hit` grows the real box; `.hit-bleed` extends a control whose drawn
  size is the design. A bigger painted pseudo-element does not enlarge what the pointer can hit unless it is
  positioned inside the control.
- Hover: surface steps one rung (`hover`), never a shadow jump on dense rows.
- Selection: filled `accent-tint` plus a 2px accent edge or check. Never colour alone.
- A selection that moves (segmented thumb, tab line or pill, radio-card ring) **glides**: `useIndicator` +
  `glideProps` and the `glide` class move its two edges separately, the one on the side it is heading first and the
  other a beat later, so it stretches toward the new choice and gathers there. Critically damped, no overshoot.
- **Edge light**: a surface's rim catches light as the pointer comes near (`ui/lib/edge-light.ts`, started by UIRoot;
  an app that marks `<html class="pui">` itself calls `startEdgeLight()` once). One passive listener updates only the
  surfaces within reach, on the frame after a move; nothing runs while the pointer is still, on touch, or with reduced
  motion. Each look sets its colour and reach (`--m-edge-light*`), or turns it off.
- Loading: controls keep their size and focus; the label morphs to a spinner. Data regions use skeletons of the
  real shape. Anything over ~2s shows elapsed time.
- Errors: inline, next to the cause, with the fix. Toasts are for background results only.

## Responsiveness

Everything works at 390px. Data tables scroll horizontally with a pinned first column rather than collapsing into
cards. Popovers become bottom sheets under 640px where the content is a form or a long list. RTL is supported:
components use logical properties (`ms-`, `pe-`, `inset-inline-start`) and directional icons flip.

## Sound

Off by default. When a product opts in, sounds are named by interaction class (tap, select, toggle-on/off,
open/close, tick) through one provider, never per component.
