# UI Polish design rules

Concrete rules for UI, with values. Before you write or change UI, read the section for what you're touching (spacing for a layout, motion for anything that moves, states for loading and errors). Before you call UI work done, go through the checklist at the end. Components built on these rules: https://uipolish.com/docs.

## Opinions

- **Leave fast, arrive slower.** Things leave in 120ms and arrive in 160 to 200ms, on an ease-out curve. Nothing eases on linear, and no transition takes longer than 380ms.
- **Springs only where something moves.** A pill sliding between tabs or a panel changing size overshoots a little, then settles. Opacity and color never bounce.
- **Busy waits 300ms before it shows.** A fast answer never flashes a spinner. Once a spinner appears, it stays at least 400ms so it can be read.
- **Every async action has four states.** Idle, busy, done and failed, each designed, with the failure saying what to do next.
- **Edges come from the text color.** Borders are the text color at 4 to 13% opacity, so they sit right on any background, in light and dark.
- **Focus is for keyboards.** A thin ring inside the control's edge on `:focus-visible`, never a halo on click.
- **Nothing shifts.** Labels that change hold their width, numbers use tabular figures, and content that loads in has its space reserved.
- **One shape per job.** Controls are 32, 40 or 48px tall, whatever they are, corners are 10, 14 or 22px, and an icon-only button is a capsule 1.375 times as wide as it is tall.
- **Native and accessible first.** Every control works from the keyboard, announces what changed, and has a 24px target for a mouse and 40px for a finger.
- **Reduced motion keeps the meaning.** Movement becomes a fade; nothing that carries meaning disappears.

## Tokens

Every rule names these roles. Add the file once, or map the roles onto variables the project already has.

```css
/*
 * UI Polish foundation tokens. Components read these roles and nothing else;
 * map them onto your own design system:
 *
 *   :root {
 *     --ui-surface: var(--background);
 *     --ui-foreground: var(--foreground);
 *     --ui-radius-md: var(--radius);
 *   }
 *
 * Colors use light-dark() and follow `color-scheme`; force a theme with
 * [data-theme="light" | "dark"] on any ancestor. Roles are redeclared on
 * [data-theme] because light-dark() resolves where it is declared, not used.
 */

:root,
[data-theme] {
  color-scheme: light dark;

  /* Space */
  --ui-space-1: 4px;
  --ui-space-2: 8px;
  --ui-space-3: 12px;
  --ui-space-4: 16px;
  --ui-space-5: 24px;
  --ui-space-6: 32px;
  --ui-space-7: 48px;
  --ui-space-8: 64px;

  /* Spacing roles, on the scale above. Pick by the relationship between two
     things, never by the component: the same relationship takes the same
     token everywhere (foundation/rules/spacing.md §1).
     The layout roles are fluid: they read the width of the nearest size
     container (cqi; the viewport when there is none), so a block in a narrow
     pane stays compact and the same block on a wide screen breathes, and
     round to the 4px grid so nothing lands between pixels. */

  /* Inside things: how far content sits in from its own edge. */
  /* A badge, tag or count pill: half its 24px height, so the label starts
     where the round end stops. */
  --ui-pad-tag: var(--ui-space-3);
  /* A 20px tag, and a tag inside a sentence. */
  --ui-pad-tag-sm: var(--ui-space-2);
  /* A row in a menu or a list in a panel, inline: never less than the row's
     radius, so its text clears the curve. */
  --ui-pad-row: var(--ui-space-3);
  /* A menu's inset around its rows. The rows take the concentric radius,
     panel − inset: 24 − 12 = 12, a rounded row, never a pill. */
  --ui-inset-menu: var(--ui-space-3);
  /* A card or panel: 16 in a phone-wide container, 24 from 560px. */
  --ui-pad-card: round(clamp(16px, 4cqi + 1.6px, 24px), 4px);
  /* A screen or page-level block: 24 narrow, 40 from 760px. */
  --ui-pad-section: round(clamp(24px, 4cqi + 9.6px, 40px), 4px);

  /* Between things. Each step is wider than the one inside it, so the
     grouping reads before anything else does. */
  /* Lines of one thing: a title and its description, a label and its value. */
  --ui-gap-tight: var(--ui-space-1);
  /* Side by side in a row: icon and label, tag and tag, button and button. */
  --ui-gap-inline: var(--ui-space-2);
  /* Items of one group: fields in a cluster, the rows of a card. */
  --ui-gap-stack: var(--ui-space-3);
  /* Groups inside a card or section, and a section's heading above its
     content: 16 narrow, 24 from 760px. */
  --ui-gap-group: round(clamp(16px, 2cqi + 8.8px, 24px), 4px);
  /* Cards and panels in a layout, and a screen's title above its content:
     16 narrow, 24 at 560px, 32 from 760px. */
  --ui-gap-block: round(clamp(16px, 4cqi + 1.6px, 32px), 4px);
  /* Sections of a screen or page: 48 narrow, 56 at 600px, 72 from 960px. */
  --ui-gap-section: round(clamp(48px, 4cqi + 33.6px, 72px), 8px);

  /* Radius */
  --ui-radius-sm: 10px;
  --ui-radius-md: 14px;
  /* Every box and card: 24, which is also the 8px inset around a 32px
     button plus that button's half-height, so a code block's corner and
     its Copy button stay concentric. */
  --ui-radius-lg: 24px;
  --ui-radius-full: 9999px;

  /* Control heights */
  --ui-control-sm: 32px;
  --ui-control-md: 40px;
  --ui-control-lg: 48px;
  /* Icon-only buttons are capsules, this much wider than tall, not circles:
     an easier target, and one shape for every button. */
  --ui-capsule: 1.375;

  /* Type */
  --ui-font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI Variable Text", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
  --ui-font-display: var(--ui-font-sans);
  --ui-font-mono: ui-monospace, "SF Mono", SFMono-Regular, "Cascadia Code", "Segoe UI Mono", "Roboto Mono", Menlo, Consolas, monospace;

  --ui-text-sm: 13px;
  --ui-text-md: 15px;
  --ui-text-2xl: 22px;
  --ui-text-display: clamp(30px, 1.6vw + 1rem, 38px);
  --ui-leading-sm: 18px;
  --ui-leading-md: 22px;
  --ui-leading-2xl: 28px;
  /* Rounded: a fractional line height pushes everything below off the pixel grid. */
  --ui-leading-display: round(calc(var(--ui-text-display) * 1.1), 1px);

  --ui-leading-prose: 1.6;
  --ui-measure: 65ch;

  --ui-text-code: 0.875em;

  /* Under 16px, iOS zooms the page when a field is focused. */
  --ui-text-touch: 16px;

  /* Weights. Fonts without a 550 round to 600. */
  --ui-weight-regular: 400;
  --ui-weight-medium: 500;
  --ui-weight-semibold: 550;

  /* Tracking. None for body text: any letter-spacing turns optional ligatures off. */
  --ui-tracking-heading: -0.01em;
  --ui-tracking-title: -0.02em;
  --ui-tracking-control: -0.01em;
  --ui-tracking-caps: 0.06em;

  /* Icon stroke: two tiers, one per label weight, each the width of the
     text's stem (DM Sans at 15px: 1.25px at 400, 1.48px at 500), in a
     16-unit viewBox drawn at 16px. Icons beside regular text take regular;
     beside a medium label (button, tab, nav or menu item) medium. A smaller
     glyph that stands on its own keeps the line: calc(tier * 16 / size).
     --ui-icon-stroke-semibold is the medium value, kept so older code
     still resolves; don't use it in new code. */
  --ui-icon-stroke-regular: 1.25;
  --ui-icon-stroke-medium: 1.5;
  --ui-icon-stroke-semibold: var(--ui-icon-stroke-medium);
  --ui-icon-stroke: var(--ui-icon-stroke-medium);

  /* Icon sizes: an icon is sized by the text it sits with, so every chevron,
     check or glyph in the same role is the same size. md beside 14-16px text
     and in every control; sm beside 13px text (sm controls, meta lines,
     notes); xs only for tiny badges and marks inside a node; lg for a glyph
     standing alone in a tile. Nothing between. */
  --ui-icon-xs: 12px;
  --ui-icon-sm: 14px;
  --ui-icon-md: 16px;
  --ui-icon-lg: 20px;
  /* Every chevron, whatever the text beside it: a chevron is a hint, so it
     sits a step under the icons around it and is one size everywhere. */
  --ui-icon-chevron: var(--ui-icon-sm);

  /* Motion. Exits run one step shorter than their entrance. */
  --ui-dur-press: 80ms;    /* going down on press */
  --ui-dur-instant: 120ms; /* color feedback; small things leaving */
  --ui-dur-fast: 160ms;    /* small things arriving; blocks leaving */
  --ui-dur-base: 200ms;    /* blocks arriving; height closing; strokes drawing */
  --ui-dur-panel: 280ms;   /* height opening */

  /* Enter: popovers, menus, tooltips, toasts, rows arriving. Between quint- and expo-out. */
  --ui-ease-enter: cubic-bezier(0.2, 1, 0.3, 1);

  /* Exit: anything leaving. Accelerates out; pair with the shorter step. */
  --ui-ease-exit: cubic-bezier(0.4, 0, 1, 1);

  --ui-scale-press: 0.985;
  /* Move: layout moves (tab pill, toast stack). A spring sampled over --ui-dur-move,
     a little underdamped (damping 0.72): it overshoots about 4% and settles. */
  --ui-dur-move: 380ms;
  --ui-ease-move: linear(0, 0.0197, 0.0712, 0.1442, 0.2303, 0.3231, 0.4171, 0.5086, 0.5948, 0.6739, 0.7446, 0.8066, 0.8597, 0.9043, 0.9409, 0.9702, 0.993, 1.0102, 1.0225, 1.0307, 1.0357, 1.038, 1.0383, 1.0371, 1.0348, 1.0318, 1.0284, 1.0248, 1.0212, 1.0177, 1.0145, 1.0116, 1.009, 1.0067, 1.0048, 1.0032, 1.0019, 1.0008, 1, 0.9994, 1);

  /* Glide: the same move without the give, critically damped. For what is
     held by a track or a count: a pill inside its group, a sheet against an
     edge, a height, a number counting to its value. */
  --ui-dur-glide: 320ms;
  --ui-ease-glide: linear(0, 0.0319, 0.106, 0.2005, 0.3013, 0.3998, 0.4917, 0.5739, 0.6459, 0.7079, 0.7606, 0.8048, 0.8416, 0.872, 0.8969, 0.9172, 0.9337, 0.9471, 0.9578, 0.9665, 0.9734, 0.9789, 0.9834, 0.9869, 0.9896, 0.9918, 0.9936, 0.995, 0.996, 0.9969, 0.9976, 0.9981, 1);

  /* Snap: short hops, press release, chevrons. A stiffer spring sampled over
     --ui-dur-snap (damping 0.8): about 1.5% past, then back. */
  --ui-dur-snap: 240ms;
  --ui-ease-snap: linear(0, 0.0284, 0.0988, 0.1935, 0.2993, 0.4069, 0.5099, 0.6045, 0.6883, 0.7607, 0.8215, 0.8714, 0.9114, 0.9427, 0.9666, 0.9842, 0.9968, 1.0053, 1.0107, 1.0137, 1.015, 1.0151, 1.0143, 1.013, 1.0115, 1.0099, 1.0083, 1.0067, 1.0054, 1.0042, 1.0032, 1.0023, 1);

  /* Bounce: a thumb landing on a stop. Overshoots, so not for anything that can hit its track's end. */
  --ui-dur-bounce: 340ms;
  --ui-ease-bounce: linear(0, 0.0293, 0.1033, 0.2045, 0.3192, 0.4369, 0.5503, 0.6543, 0.7461, 0.8243, 0.8886, 0.9397, 0.9787, 1.0072, 1.0266, 1.0388, 1.0451, 1.0471, 1.0458, 1.0424, 1.0377, 1.0323, 1.0268, 1.0214, 1.0165, 1.0122, 1.0084, 1.0054, 1.003, 1.0011, 0.9997, 0.9988, 0.9982, 0.9979, 0.9978, 0.9978, 1);

  /* Expand: grid-template-rows height opening, over --ui-dur-panel. */
  --ui-ease-expand: cubic-bezier(0.32, 0.72, 0, 1);

  /* Collapse: height closing, over --ui-dur-base. Eases in while the content
     fades, then folds and stops without a tail. */
  --ui-ease-collapse: cubic-bezier(0.4, 0, 0.2, 1);

  /* Color: in-place color and opacity feedback, over --ui-dur-instant. */
  --ui-ease-color: cubic-bezier(0.2, 0, 0, 1);

  /* Draw: SVG strokes drawn with stroke-dashoffset, over --ui-dur-base. */
  --ui-ease-draw: cubic-bezier(0.35, 0, 0.15, 1);

  /* Pulse: `alternate` loops, --ui-dur-pulse per half cycle; sweeps run linear. */
  --ui-ease-pulse: cubic-bezier(0.37, 0, 0.63, 1);
  --ui-dur-pulse: 700ms;
  --ui-dur-sweep: 2400ms;

  /* Color. Text roles hold 4.5:1 on canvas and surface in both themes, and
     no more than they need: the darkest text isn't black and the lightest
     isn't white, so a page reads firm without glaring. */
  --ui-canvas: light-dark(oklch(0.99 0 0), oklch(0.145 0 0));
  --ui-surface: light-dark(oklch(1 0 0), oklch(0.19 0 0));
  --ui-foreground: light-dark(oklch(0.21 0 0), oklch(0.93 0 0));
  --ui-muted: light-dark(oklch(0.5 0 0), oklch(0.71 0 0));
  --ui-subtle: light-dark(oklch(0.55 0 0), oklch(0.62 0 0));
  /* Edges: one solid neutral for every line, outline or divider. A card's
     frame, a field, a button, a checkbox, a table's row rules, a key cap:
     the same color at the same 1px weight, so no line reads darker,
     lighter, thicker or thinner than another. Solid, not a see-through
     tint: a tint came out darker over the page than over a card or a
     field, so the same token drew two different lines.
     --ui-border-strong (drawn by --ui-edge) and --ui-border are the same
     value, two names kept so existing code resolves; --ui-border-subtle
     too. Only hover, focus and selected step darker, to --ui-field-focus.
     Colored ones mix a hue at --ui-border-tint: the status edges below, or
     a component's own hue (a phase, a brand) at the same strength. */
  --ui-border-strong: light-dark(oklch(0.954 0 0), oklch(0.26 0 0));
  --ui-border: var(--ui-border-strong);
  --ui-border-subtle: var(--ui-border);
  --ui-border-tint: 32%;
  --ui-fill: color-mix(in oklch, var(--ui-foreground) 4.5%, transparent);
  --ui-fill-strong: color-mix(in oklch, var(--ui-foreground) 8%, transparent);
  /* The same two fills, opaque, for what must hide what's under it (a
     sticky cell, an avatar, a shell around panels): never a third gray. */
  --ui-fill-solid: color-mix(in oklch, var(--ui-foreground) 4.5%, var(--ui-surface));
  --ui-fill-strong-solid: color-mix(in oklch, var(--ui-foreground) 8%, var(--ui-surface));
  --ui-ink: var(--ui-foreground);
  --ui-on-ink: light-dark(oklch(0.99 0 0), oklch(0.17 0 0));
  /* Secondary text on ink (a shortcut in a tooltip): the muted step. */
  --ui-on-ink-muted: color-mix(in oklch, var(--ui-on-ink) 60%, var(--ui-ink));
  /* Each theme has its own tuned hues; fills and icons hold 3:1, text 4.5:1. */
  --ui-accent: light-dark(oklch(0.55 0.22 262), oklch(0.6 0.19 258));
  --ui-on-accent: oklch(0.99 0 0);
  --ui-success: light-dark(oklch(0.6 0.16 150), oklch(0.7 0.17 150));
  /* Text on a success fill (a Merge button): white on light, ink on the lighter dark-theme green. */
  --ui-on-success: light-dark(oklch(0.99 0 0), oklch(0.17 0 0));
  --ui-warning: light-dark(oklch(0.67 0.155 58), oklch(0.78 0.15 72));
  --ui-danger: light-dark(oklch(0.58 0.22 27), oklch(0.62 0.21 25));
  --ui-on-danger: oklch(0.99 0 0);
  --ui-accent-text: light-dark(oklch(0.49 0.21 263), oklch(0.76 0.12 255));
  --ui-success-text: light-dark(oklch(0.5 0.13 150), oklch(0.8 0.17 152));
  --ui-warning-text: light-dark(oklch(0.53 0.13 52), oklch(0.85 0.135 82));
  --ui-danger-text: light-dark(oklch(0.51 0.2 27), oklch(0.74 0.15 22));
  --ui-scrim: light-dark(oklch(0.2 0 0 / 0.2), oklch(0 0 0 / 0.5));
  --ui-selection: color-mix(in oklch, var(--ui-accent) 24%, transparent);
  --ui-border-accent: color-mix(in oklch, var(--ui-accent) var(--ui-border-tint), transparent);
  --ui-border-success: color-mix(in oklch, var(--ui-success) var(--ui-border-tint), transparent);
  --ui-border-warning: color-mix(in oklch, var(--ui-warning) var(--ui-border-tint), transparent);
  --ui-border-danger: color-mix(in oklch, var(--ui-danger) var(--ui-border-tint), transparent);
  /* Tints: the one fill behind status text (a badge, a pill, a changed
     line). Stronger in dark, where a low-alpha hue disappears. */
  --ui-accent-tint: light-dark(color-mix(in oklch, var(--ui-accent) 10%, transparent), color-mix(in oklch, var(--ui-accent) 16%, transparent));
  --ui-success-tint: light-dark(color-mix(in oklch, var(--ui-success) 10%, transparent), color-mix(in oklch, var(--ui-success) 16%, transparent));
  --ui-warning-tint: light-dark(color-mix(in oklch, var(--ui-warning) 10%, transparent), color-mix(in oklch, var(--ui-warning) 16%, transparent));
  --ui-danger-tint: light-dark(color-mix(in oklch, var(--ui-danger) 10%, transparent), color-mix(in oklch, var(--ui-danger) 16%, transparent));

  /* Focus: a line just inside the control's edge; fields darken their edge instead. */
  --ui-ring-focus: 1px solid color-mix(in oklch, currentColor 45%, transparent);
  --ui-ring-offset: -1px;
  --ui-field-focus: color-mix(in oklch, var(--ui-foreground) 26%, var(--ui-border-strong));

  /* Every edge and divider: one CSS pixel at a faint alpha, drawn as a
     box-shadow (a ring, or an inset line on one side), never a `border`.
     A half-pixel line drew crisp on straight sides but fuzzy and faint on
     rounded corners; a full pixel at half the alpha carries the same ink
     and draws the same on curves and straights. */
  --ui-hairline: 1px;

  /* Elevation, light mode only: three steps, each the edge plus a shadow in
     the same ink, so the line and the shade read as one soft depth. Every
     step is a tight contact layer under a wide, very faint ambient one; the
     steps grow in reach, not in darkness at the edge.
     Resting: cards and panels on the page. Raised: popovers, a featured
     card, a hovered card. Floating: menus, dialogs, toasts.
     Dark mode has no shadows at all: depth there comes from the surface
     being lighter than the canvas, and from the edges. */
  /* --ui-edge: a box's outline as a ring in place of a border, so it takes
     no layout space. The same color as a field's edge: a card and the
     controls in it read as one family. Already part of the resting and
     floating shadows; never stack it on one of them, two rings read as a
     darker edge. Raised carries no ring: pair it with --ui-edge. */
  --ui-edge: 0 0 0 var(--ui-hairline) var(--ui-border-strong);
  /* A track of choices (tabs, a segmented control): clear inside a hairline
     edge, with one flat pill under the chosen item, inset from every side.
     Both components read these, so they always look like one family. */
  --ui-track-edge: inset 0 0 0 var(--ui-hairline) var(--ui-border-strong);
  --ui-track-inset: 4px;
  --ui-track-pill: var(--ui-fill-strong);
  /* The ambient layer barely drops (2px) and spreads wide, so the soft
     shade wraps the whole card instead of pooling at its bottom edge. */
  /* The edge, then only a wide soft spread that starts below it: nothing
     darkens the line itself, so a card's edge is the same line as a
     field's or a code block's, which carry no shadow. */
  --ui-shadow-resting:
    var(--ui-edge),
    0 12px 32px -12px light-dark(oklch(0.21 0 0 / 0.09), transparent);
  --ui-shadow-raised:
    0 1px 2px light-dark(oklch(0.21 0 0 / 0.05), transparent),
    0 4px 12px -4px light-dark(oklch(0.21 0 0 / 0.06), transparent),
    0 16px 40px -12px light-dark(oklch(0.21 0 0 / 0.12), transparent);
  --ui-shadow-floating:
    var(--ui-edge),
    0 2px 4px light-dark(oklch(0.21 0 0 / 0.05), transparent),
    0 12px 28px -8px light-dark(oklch(0.21 0 0 / 0.1), transparent),
    0 32px 72px -24px light-dark(oklch(0.21 0 0 / 0.16), transparent);
  /* Every button, filled or not: the resting shadow's soft spread at a
     control's scale. It starts below the edge, so a button's bottom line
     is the same line as its top, never a darker one. */
  --ui-shadow-control: 0 3px 6px -4px light-dark(oklch(0.21 0 0 / 0.08), transparent);
  /* A ring in the surface's color that cuts a dot or avatar out of what it
     overlaps (a status dot on an avatar, a stack of faces). */
  --ui-knockout: 2px;
}

[data-theme="light"] { color-scheme: light; }
[data-theme="dark"] { color-scheme: dark; }

/* Code drops ligatures, so != and => stay two characters. */
:where(code, kbd, pre, samp) {
  font-variant-ligatures: none;
}

/* A textarea wraps, so it never scrolls sideways: some browsers still show
   a horizontal scrollbar for a long word or a trailing space. */
:where(textarea) {
  overflow-x: hidden;
  overflow-wrap: break-word;
}
```

# Foundations

Goal: one system for everything downstream. Nothing here is visible on its
own. It sets up the tokens and global defaults that keep every other rule
consistent, and fixes the details users only notice when they're wrong.

The UI Polish token roles live in `tokens.css` (`--ui-*`). Map each role onto
the project's existing variable instead of adding a second system.

## 1. Token inventory

Read the project's existing tokens first. Consolidate before decorating:

- **Durations & easings**: if the project animates with literals, introduce
  the ladder from [motion.md](motion.md) as CSS custom properties (or the
  project's theme keys) and migrate existing literals to it as you touch them.
- **Radius**: one scale (e.g. 10 / 14 / 22 + full). See
  [surfaces.md](surfaces.md).
- **Shadows, borders, semantic colors**: name what repeats. Three ad-hoc
  grays doing the same job become one token.
- Put new tokens where the project already keeps them (its theme config or
  `:root`). Never inline a value that two components share.

Do not rename or restructure existing tokens — extend only.

## 2. Focus (one definition, everywhere)

The most common focus defect is `outline: none` with nothing in its place. The
second is a ring around the control, dark or haloed, that shouts louder than
the control itself. Draw focus inside the control's own edge instead: a thin
line in the control's text color, so it reads on a dark primary button and a
light ghost button alike, and nothing grows outside the shape.

```css
:focus-visible {
  outline: 1.5px solid color-mix(in oklch, currentColor 55%, transparent);
  outline-offset: -1.5px; /* hugs the inside of the edge, follows the radius */
}
```

Fields darken and thicken their edge, nothing outside:

```css
.input:focus {
  box-shadow: inset 0 0 0 1px var(--ui-field-focus);
}
```

Invalid fields use the same edge in the danger color.

- `:focus-visible`, not `:focus`, on buttons and links: clicks shouldn't paint
  focus. Text fields show it on `:focus`, because the caret already tells.
- Check it on every surface a control sits on: if the line disappears
  (a control whose text color matches its fill), raise its alpha.
- Pick one field treatment and use it on every field.
- Never animate it; never remove it.

## 3. Selection, caret, taps

Cheap, high-signal details — each one line, applied once:

```css
::selection { background: /* accent at ~20-30% */; color: /* readable fg */; }
html { -webkit-tap-highlight-color: transparent; }
```

Custom selection color is near-universal in polished products and absent in
unpolished ones. Verify selected text stays readable (don't use a dark accent
with dark text).

## 4. Global rendering hygiene

```css
html {
  -webkit-font-smoothing: antialiased;
  scrollbar-gutter: stable;        /* no layout shift when dialogs lock scroll */
}
* { min-width: 0; }                /* flex/grid children can actually shrink */
```

- If the project uses `scroll-behavior: smooth`, pair it with
  `@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } }`.
- Scroll lock for modals: `html:has(dialog[open]) { overflow: hidden; }`
  (with `scrollbar-gutter: stable` this is shift-free).
- Dark mode: verify `color-scheme: light dark` (or the applicable one) is set
  so native controls, scrollbars, and form elements match the theme.

## 5. Interaction affordances

- Two cursors only: `pointer` on things that act, `default` everywhere
  else, including disabled, busy and draggable things. No `not-allowed`,
  `progress`, `grab` or `wait`; the state belongs in the control's look, not
  the cursor. Text inputs keep the browser's own text cursor.
- Hit areas ≥ 40×40px for touch targets. Grow small icon buttons with
  padding or a pseudo-element (`::after { inset: -8px }`), never by scaling
  the icon.
- `user-select: none` on button labels and other pure-control text so
  double-clicks don't highlight chrome; never on content.
- `touch-action: manipulation` on buttons removes the 300ms-legacy/double-tap
  ambiguity on some mobile browsers.

## 6. Semantic sweep

While you're at the foundation layer, fix what CSS can't:

- Clickable `<div>`s → `<button>` (type="button") or `<a>`. This is the
  cheapest accessibility *and* polish win — buttons get focus, Enter/Space,
  and touch behavior for free.
- Icon-only buttons get `aria-label`.
- Images get real `alt` text (or `alt=""` when decorative) and explicit
  dimensions/`aspect-ratio` to prevent layout shift.
- Exactly one `<h1>` per view; heading levels don't skip.

## 7. Check before moving on

- Tab through the main flow: every stop visible, order sensible.
- Select text on a few surfaces: readable.
- Open a modal: no background scroll, no width jump.
- Toggle dark mode (if present): tokens hold, no hardcoded whites/blacks
  bleeding through.

# Typography

Goal: hierarchy you can feel without reading. Work top-down: fix the scale, then
line-height, then wrapping, then details. If the project already has a type
scale, patch its weak steps instead of replacing it.

## 1. The scale

A type step is a size **with the line-height it is set on**, plus a weight.
Never ship a bare `font-size`, and never pick a value between steps: if a step
is missing, the scale is wrong, not the component.

**Four styles, and only four.** A fifth size is never the answer: pick the
nearest of these, or change the layout.

| Style | Token | Size | Line-height | Weight | Use |
|---|---|---|---|---|---|
| Title | `display` | clamp(30px, 1.6vw + 1rem, 38px) | 1.1 | 500 | Page titles, h1 |
| Heading | `2xl` | 22px | 28px | 500 (550 in components) | Section headings, h2, big figures |
| Body | `md` | 15px | 22px | 400; 500 for control labels | Text, controls, menus, fields, subheads |
| Small | `sm` | 13px | 18px | 400; 500 for badges | Secondary text, captions, field messages, meta |

Running text of more than a couple of lines takes `--ui-leading-prose` (1.6)
at Body size. Inline code is mono at `--ui-text-code` (0.875em) of the text
it sits in, which is the same style in another face, not a new one.

Tokens: `--ui-text-{sm,md,2xl,display}`, `--ui-leading-{sm,md,2xl,display}`
(the old `xs`, `lg` and `xl` steps alias into these),
`--ui-leading-prose`, `--ui-text-code`, `--ui-weight-{regular,medium,semibold}`,
`--ui-measure`, `--ui-text-touch`.

- Line-heights are px for the UI steps so rows line up: 13px text in a 20px
  row (a footer beside 14px text, a field message) takes `--ui-leading-md`,
  not its own 18px.
- Controls, pills and badges use `line-height: 1` so padding alone sets the
  box. That's a rule, not a step.
- Weight: 400 to read, 500 for labels and controls (12–14px UI text at 400
  looks thin), 550–600 for titles. Large display type never goes past 600.
- Mono uses the step it sits in: code blocks at `sm` / `md` leading, inline
  code at `--ui-text-code` (mono sets wider than the text face, so the same
  size looks bigger).

Keep each step's size, line height and weight together as one set of
tokens, so they're never mixed across steps:

```css
:root {
  --text-2xl: 22px;
  --text-2xl-line-height: 28px;
  --text-2xl-weight: 600;
}
```

## 2. Letter-spacing: text untracked, headings a touch tighter

**Text up to 17px is untracked.** `letter-spacing` stays `normal` on body,
labels and components. Two reasons, both measured in Chromium and WebKit with
system-ui:

- **Any letter-spacing switches optional ligatures off.** At −0.01em, −0.03em
  and +0.06em alike, Chromium drops the contextual forms of the system face
  (the colon in `12:30` drops from figure height to the baseline) and the
  fi / fl / ffi ligatures of faces that have them. WebKit keeps the system
  face's contextual forms but drops other faces' ligatures.
  `font-variant-ligatures` never brings them back; `font-feature-settings`
  brings back some in one engine and not the other. There's no reliable
  override, only not tracking.
- **The system face already tracks itself.** SF Pro switches optical size
  with font size (`font-optical-sizing: auto`, the default) and tightens as
  it grows: the same line is about 0.06em per letter tighter at 76px than at
  11px, with no letter-spacing. Negative tracking on a heading doubles that.

**Headings from 22px up carry a little negative tracking**: `--ui-tracking-heading`
(−0.01em) at 22px and `--ui-tracking-title` (−0.02em) on display sizes. The
face's own tightening is tuned for reading, and a heading set at its default
reads as separate letters instead of one shape. At these sizes the system
face has no ligatures to lose, and a heading rarely contains a time, so the
cost from the first point doesn't apply. Set it on the heading's class, never
on the `h1`–`h3` elements globally: a small `h2` used as a 13px label must
stay untracked.

The other exception is all-caps: short uppercase labels need air,
`letter-spacing: var(--ui-tracking-caps)` (0.06em). There are no ligatures
to lose in a three-word uppercase label.

If the project ships its own web font that has no optical sizes, a large
display step can carry a small negative tracking. Check it in Chromium with a
word that has fi or fl and see what you lose.

## 3. Line-height

| Role | Line-height |
|---|---|
| Display (32–44px) | 1.1 |
| Headings (17–22px) | 24 / 28px |
| UI text (12–15px) | 16 / 18 / 20 / 22px |
| Running text, any size | 1.6 |
| Buttons, pills, badges, labels | 1, so padding alone controls the box |

The most common single miss: headings inheriting body line-height. A 22px
heading at 1.5 has a hole in the middle of the layout.

## 4. Wrapping, measure, truncation

- **`text-wrap: pretty` is the default for all body text, not an opt-in.**
  Set it once on the root (`html { text-wrap: pretty; }`); it inherits into
  everything, so no component has to remember it, it costs nothing where
  lines don't wrap and removes orphans everywhere they do. Its absence on
  any multi-line paragraph is a defect.
- `text-wrap: balance` globally on every heading, caption and legend
  (`:where(h1, h2, h3, h4, h5, h6, caption, figcaption, legend) { … }`), and on
  any other text ≤ 3 lines (card titles, section intros, empty-state
  messages). Balance for headings and short blocks, pretty for prose. Don't
  put balance on long paragraphs; browsers ignore it past a few lines anyway.
- Prose measure: `max-width: var(--ui-measure)` (65ch) on paragraphs and
  lists. A container can be wider for tables and code; the paragraphs inside
  it can't. Heading measure is *narrower* than body measure so display lines
  break 2–3 words in.
- `hanging-punctuation: first` on prose, so an opening quote hangs outside
  the text edge (Safari; ignored elsewhere).
- `white-space: nowrap` on pills, buttons and table headers.

### Wrap, don't cut

Text a person needs to read in full (a name, an email, a file name, a page
title, a chip's label) wraps. A long unbroken string breaks inside itself
only when there's nowhere else to break, and the box around it grows: a chip
becomes a rounded box, a row gets taller. Cutting it with "…" or a fade hides
exactly the part that tells two items apart.

```css
.fileName { composes: wrap from "../text/text.module.css"; }
```

Cut text only where the height is fixed for a reason: rows of a virtualized
table, a single-line native input. There, use a fade, never an ellipsis,
and put the full text in `title` too. That's the one exception.

A read-only value inside a fixed-height control (a copy field, a token, a
URL) is the other case: one line, the end faded, the full value in `title`.
Wrapping inside a control's height spills out of it.

### When text must be cut: fade, not an ellipsis

Fade its last 24px out instead of cutting to "…". **Only fade when the text
is actually cut.** A fade on a label that fits looks like a rendering bug.

```css
.cellValue { composes: truncate from "../text/text.module.css"; }
.excerpt   { composes: truncateLines from "../text/text.module.css"; --truncate-lines: 2; }
```

It needs no script. `overflow: hidden` makes the element a scroll
container, and it only has a scroll range while the content overflows. An
animation on `scroll(self inline)` sets the fade width; with no range the
timeline is inactive and the fade stays 0. The mask runs `to right`, or
`to left` inside a `dir="rtl"` ancestor. Write that as
`:is([dir="rtl"]) .x`, not `:dir(rtl)`: the CSS transpiler rewrites
`:dir()` into a list of `:lang()` matches, which misses `dir` attributes.
Browsers without scroll timelines fall back to
the ellipsis.

- The full string stays in the DOM, so screen readers read all of it.
  Where the cut part carries meaning (an extension, a title), also put it in
  `title`. That's a convenience for mouse users, not the fix: if the cut text
  is the only way to tell two rows apart, give it room.
- Never truncate the only label on a control, an error message, or copy you
  wrote: shorten or wrap instead.
- Nothing else on the element may set `animation` or `mask-image`. Put a
  sweep or shimmer on a span inside; nested masks multiply.

### Tabular figures need a face that has them

`font-variant-numeric: tabular-nums` does nothing in a font without the
`tnum` feature. Check that the font ships it (measure two digits' widths
with and without it) before relying on it to stop numbers jiggling. A
readout that must keep its width without them reserves the width of its
widest value.

### A badge or pill inside a sentence

Shorter than the line (20px in a 24px line), its label on the sentence's
baseline, 2px of room at each side. Never as tall as the line: it touches
the lines above and below, and punctuation butts against it.

## 5. Numerics & details

- `font-variant-numeric: tabular-nums` on anything numeric that changes or
  aligns vertically: timers, counters, file sizes, prices in lists, line
  numbers, stat tiles. A two-second fix that kills row jitter.
- Curly quotes and real apostrophes (’ “ ”) in interface copy; straight
  quotes stay in code.
- Ellipsis character (…) not three periods, in copy like "Searching…".
- All-caps eyebrow labels: `xs`, uppercase, `--ui-tracking-caps`, muted.
  Don't use `font-variant: small-caps` with fonts that don't have them.
- Code turns ligatures off (`font-variant-ligatures: none`), so `!=` and
  `=>` stay two characters. tokens.css does this once for `code, kbd, pre,
  samp`; put mono text in those elements rather than a styled span. Set mono
  with `font-family` and the size longhands: a `font:` shorthand resets
  `font-variant-ligatures` and turns them back on.

## 6. Icons beside text

An icon next to a label should look like the same weight as the label's
letters: **icon stroke matches the text's stem**.

- Measured on the system face (stem of `l` at 3× scale): a vertical stem is
  about **0.085em at 400, 0.104em at 500, 0.123em at 600**. 14px / 500 text
  has a 1.46px stem; 13px / 400 has 1.10px.
- A stroke in a 16-unit viewBox renders at `size / 16 × stroke` px, so the
  stroke number is `stem × 16 / icon size`. A 14px icon needs a slightly
  higher number than a 16px one for the same weight.
- Size: 16px icons beside 14–16px text, 14px beside 13px (the scale is
  under "Icon sizes" below). The icon's drawn
  height (about 9.5 of 16 units) then sits between the x-height and the cap
  height. Centre it on the line box, not the baseline; with `line-height: 1`
  and `align-items: center` the system face is already optically centred.
- **A control beside a label that can wrap** (checkbox, radio, switch) sits
  on the first line, its centre on the middle of that line's capitals:
  baseline-align it, then shift by `size / 2 − 0.5cap`. Give the `cap` unit
  a fallback (`@supports not (height: 1cap)` → `0.35em`, half the system
  face's cap height): without one, Safari before 17.2 drops the control onto
  the baseline, 2–6px high. A control with no label is centred in its cell.

Tokens (16-unit viewBox numbers, measured against DM Sans at 3x: a 15px
stem is 1.25px at 400 and 1.48px at 500):

| Token | Value | Text it matches |
|---|---|---|
| `--ui-icon-stroke-regular` | 1.25 | 400 |
| `--ui-icon-stroke-medium` | 1.5 | 500 |
| `--ui-icon-stroke` | medium | what icons read |

**Two tiers, picked by the weight of the label beside the icon.** Icons
read `stroke-width: var(--ui-icon-stroke)`. Set it once where the text
weight is known: buttons, tabs, segments, nav items and chips (500) set
medium; field adornments, menu and palette rows, and icons in running text
(400) set regular. An icon-only button is a button: medium. No blends
between the tiers, no third tier (`--ui-icon-stroke-semibold` is the medium
value, kept for older code), no literal `stroke-width`. A glyph drawn
smaller than 16px on its own (in a node or a tile) keeps the line:
`calc(var(--ui-icon-stroke-medium) * 16 / 14)` at 14px. Don't add weight
to an icon on a filled control to "hold up": match the label.

### Icon sizes

Four sizes, picked by the text the icon sits with, never by the component:

| Token | Size | Where |
|---|---|---|
| `--ui-icon-md` | 16px | Beside 14–16px text, and in every control (button, field, select, menu row) |
| `--ui-icon-sm` | 14px | Beside 13px text: sm controls, meta lines, notes, a 13px disclosure |
| `--ui-icon-xs` | 12px | Only a tiny mark in a node, a corner badge or a 24px tile |
| `--ui-icon-lg` | 20px | A glyph alone in a 36–44px tile |

- **Every chevron is `--ui-icon-chevron` (14px)**, whatever the text
  beside it: accordion, reasoning, tool steps, sources, sidebar rows,
  select and menu triggers. A chevron is a hint, not a glyph: at the
  icons' 16px it outweighs the label. The Icon component sizes any
  `chevron-*` this way, so a chevron needs no size of its own.
- **No sizes in between** (15, 18, 22px). A glyph that needs to read bigger
  steps to `lg`; one in a tight node steps to `xs`.
- Sizes read the tokens, never a literal: `width: var(--ui-icon-sm)`.

## 7. Responsive type

Discrete 2–3 stop jumps for almost everything:

```
text-2xl md:text-3xl              /* card/section headings */
text-4xl md:text-5xl lg:text-6xl  /* page headings */
text-5xl md:text-7xl lg:text-8xl  /* hero */
```

Reserve `clamp()` for hero display type only. Give it a rem intercept so small
viewports don't collapse: `font-size: clamp(2rem, 5vw + 0.35rem, 4rem)`. When a
title comes from a CMS or user data, size conditionally on length — long titles
get one step smaller and a wider container.

**Form inputs: minimum 16px on touch** or iOS Safari zooms the page on focus:
`@media (pointer: coarse) { .input { font-size: var(--ui-text-touch); } }`.

Anchor targets under a sticky header need `scroll-margin-top` (roughly header
height + 16px), or in-page links bury headings under the nav.

## 8. Text color hierarchy

Pick ONE system and use it everywhere:

- **Alpha ladder** on the foreground color: 100% headings / 80% body /
  60–70% secondary / 50% meta / 40% disabled-ish. Six rungs ~10% apart is the
  finest distinction that stays perceptible.
- **Token ramp**: gray-900 headings, gray-700 body, gray-600 secondary,
  gray-400 placeholder — with `light-dark()` or dark-mode overrides. The
  mid-tone secondary color often works unchanged in both modes.

Either way, secondary text must stay ≥ 4.5:1 contrast for body sizes. The
token ramp here is `--ui-foreground` / `--ui-muted` / `--ui-subtle` at 15 / 46 /
55% lightness (96 / 72 / 60% dark); all three hold 4.5:1 on canvas and
surface in both themes. `--ui-subtle` on a filled control sits just under, so
use `--ui-muted` there.
A refined default: body copy slightly muted, headings at full contrast — the
hierarchy reads even in a squint test.

## 9. Rendering hygiene (once, globally)

```css
html {
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
  scrollbar-gutter: stable;      /* kills layout shift when modals lock scroll */
}
```

System font stack as the default: `system-ui, -apple-system,
BlinkMacSystemFont, "Segoe UI Variable Text", "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif` plus the color emoji faces. Nothing to
download, no layout shift, and each platform's best-tuned UI face.

Ligatures, contextual forms, kerning and optical sizing are all **on by
default**. Leave them on by not fighting them:

- No `letter-spacing` on text under 22px; headings use the heading tokens (section 2).
- Never `font-optical-sizing: none`: WebKit then sets every size in the
  display cut, too tight below 20px.
- Prefer `font-variant-*` to `font-feature-settings`. Feature settings don't
  merge: a component that sets `"tnum"` that way silently drops every other
  feature it inherited.
- A `font:` shorthand resets `font-variant-ligatures`, `font-kerning` and
  `font-optical-sizing` to their defaults. For text that's harmless (the
  defaults are on); for code it turns ligatures back on.

On Apple the system face has no fi / fl ligatures to show; what tracking costs
it is its contextual forms. On Windows and Android the system faces' own
ligatures are what tracking turns off.

If the project ships web fonts: `font-display: swap` on every face, `.woff2`
only, preload only the one face used above the fold. Never add a new font
dependency yourself.

# Spacing & layout

Goal: spacing that encodes relationships. The gap between two elements should
tell the user whether they belong together — proximity is hierarchy. Most
"feels cramped/feels scattered" complaints are grouping problems, not
quantity-of-whitespace problems.

## 1. The system

Base unit 4px, favor steps that land on 8: **8, 12, 16, 20, 24, 32, 40, 48,
64, 80, 96**. If the project has a spacing scale, use its steps exclusively;
hunt down one-off values (`mt-[13px]`, `margin: 17px`) and snap them to the
scale.

### Spacing roles: pick by relationship, never by component

On top of the raw scale (`--ui-space-1`…`8`) sit a few roles, one per
relationship. Ask *what is this space between, or inside?* and take that
role; never pick a number because of which component you're in. The same
relationship gets the same token everywhere, so a page's rhythm holds when
pieces move between screens.

| Role | Value | For |
|---|---|---|
| `--ui-pad-tag` | 12 | Inside a badge, tag or count pill: half its 24px height, so the label starts where the round end stops |
| `--ui-pad-tag-sm` | 8 | A 20px tag, and a tag inside a sentence |
| `--ui-pad-row` | 12 | Inside a row of a menu or a list in a panel, inline. Never less than the row's radius |
| `--ui-inset-menu` | 12 | A menu's inset around its rows (rows take panel radius − inset) |
| `--ui-pad-card` | 16 → 24 | Inside a card or panel |
| `--ui-pad-section` | 24 → 40 | Inside a screen, a page-level block, a demo stage |
| `--ui-gap-tight` | 4 | Lines of one thing: a title and its description, a label and its value |
| `--ui-gap-inline` | 8 | Side by side in a row: icon and label, tag and tag, button and button |
| `--ui-gap-stack` | 12 | Items of one group: fields in a cluster, the rows of a card |
| `--ui-gap-group` | 16 → 24 | Groups inside a card or section; a section's heading above its content |
| `--ui-gap-block` | 16 → 32 | Cards and panels in a layout; a screen's title above its content |
| `--ui-gap-section` | 48 → 72 | Sections of a screen or page |

- **Fluid by container, on the grid.** The `→` roles grow with the width of
  the nearest size container (`cqi`; the viewport when there is none) and
  round to the 4px grid (sections to 8): `round(clamp(min, slope, max), 4px)`.
  A block in a narrow pane stays compact, the same block on a wide screen
  breathes, and no value lands between pixels. Make the frame a block lives
  in (a demo stage, a pane) a size container so its blocks read its width.
- A role resolves where it's used: on an element that is itself a container,
  `cqi` reads the container *around* it. Set section and block gaps on a
  container's children, or accept that its own gap reads the frame.
- Keep a component's own variable when several rules share one inset
  (`--pricing-inset: var(--ui-pad-card)`), so they move together.
- Reach for the raw scale only inside a component's own geometry (a 2px
  optical nudge, a control's internal inset); never for the space between
  cards, groups or sections.

Ratio rule for grouping: **space within a group ≤ half the space between
groups.** A label sits 4–8px from its input; that pair sits 16–24px from the
next field; the whole form sits 48–96px from the next section. If inner and
outer gaps are equal, the eye can't parse structure.

## 2. Vertical rhythm

- Inside sections and stacks, use `gap` (flex/grid) or `space-y-*`, not
  per-child margins. Margins on children leak and collapse; gaps are owned by
  the parent.
- Canonical ladder, as roles: `--ui-gap-inline`/`--ui-gap-stack` inside a
  control cluster, `--ui-gap-group` heading → body, `--ui-gap-block`
  between cards and subsections, `--ui-gap-section` between major blocks.
  (Tailwind: `gap-2`/`gap-3`, `gap-6`, `gap-8`, `gap-12`+.)
- Section padding scales with a clean multiplier from mobile to desktop —
  1.5–2×, never an ad-hoc pair:

```
py-12 md:py-24     /* standard content section */
py-16 lg:py-24     /* moderate */
py-20 lg:py-40     /* large marketing section */
```

- In prose, space *before* a heading should be ~2–3× the space after it, so
  the heading visually attaches to what it introduces. Modern CSS can derive
  this instead of hardcoding per element:

```css
:is(p, ul, ol, blockquote) { margin-block-end: 1em; }
h2 { margin-block-end: 0.75em; }
*:has(+ h2) { margin-block-end: 2.5em; }      /* big gap before a heading */
h2:has(+ h3) { margin-block-end: 0.75em; }    /* unless it's a stacked subhead */
p:has(+ ul) { margin-block-end: 0.25em; }     /* a list belongs to its lead-in */
```


## 3. Horizontal gutters & containers

- One gutter ladder for the whole app: `px-4 sm:px-6 lg:px-8` (conservative
  app chrome) or `px-5 lg:px-20` (airy marketing). Not both.
- Better: a gutter token so everything computes from it:

```css
:root { --gutter: 1rem; }
@media (min-width: 1024px) { :root { --gutter: 2rem; } }
.container { max-width: 80rem; margin-inline: auto; padding-inline: var(--gutter); }
```

- Container widths: ~1280–1400px for full layouts, `max-w-3xl` for section
  intros, `max-w-2xl`/`65ch` for reading columns. The `min()` idiom replaces
  max-width + padding pairs: `width: min(1340px, 100% - 2 * var(--gutter))`.

## 4. Alignment

- Edges align or they don't — there is no "close". Check that section
  headings, body copy, and cards share a left edge; ragged starts read as
  broken.
- Optical alignment beats box alignment: icons next to text usually need a
  1px nudge; play-style triangles need centering by visual mass. A text
  baseline next to a larger sibling wants `items-baseline`, not `items-center`.
- Icon + label gaps: 6–8px (`gap-1.5`/`gap-2`). Icon size ~1.0–1.2× the text's
  cap height — 16px icons with 14px text, 20px with 16px.
- Fixed-position chrome (toasts, FABs) offsets from the viewport by the same
  step everywhere (16 or 24px), and respects safe-area insets on mobile:
  `bottom: max(1rem, env(safe-area-inset-bottom))`.

## 5. Component-level spacing

- **Buttons**: horizontal padding ≈ 2–2.5× vertical (e.g. `px-4 py-2`).
  Heights come from one scale, 8px apart: 32 / 40 / 48px
  (`--ui-control-sm/md/lg`). Steps closer than 8px read as a mistake beside
  each other, and a jump of 14 reads as two different families. Everything
  that sits in a row with a button takes a height from this scale, including
  composite controls: a segmented control or a switch is measured whole,
  track included, not by its inner pill. Icon-only buttons are
  square, same height as their text siblings.
- **Buttons are the standard control height (40).** Page actions, card and
  row actions, dialog footers, banners, empty states, form submits and demo
  toolbars all use the default size. The small size (32) is only for a
  button inside another control or a compact control strip, where 40 would
  break the control: Copy in a code block's header, send or attach in a
  prompt input, an icon button in a dense toolbar or a row's overflow menu.
  When in doubt, use the default. A row that holds a button is at least 40
  tall; a ghost button that lines up with text beside it pulls back by its
  20px padding, not the small size's 14.
- **Controls in one row share one height.** A toolbar, a filter bar, a
  form's actions or a demo's controls pick one step and every control in
  the row takes it: a search field (40px) sets its select and segmented
  control to 40, not 32 beside it. Only a control nested inside another
  (a field's clear button, the copy button inside a command pill) is
  smaller, inset evenly on every side.
- **Cards**: one internal padding value per card (`--ui-pad-card`), not
  different values per side. Media that bleeds to the card edge needs the
  card's `overflow-hidden` and radius handling (see surfaces.md).
- **Menus and lists in a panel** (dropdown and context menus, select and
  combobox lists, the command palette, a model picker): rows are rounded
  rectangles, never pills. The panel insets its rows evenly and each row
  takes the concentric radius, panel radius − inset (a menu: 24 − 12 = 12;
  a field's list: 14 − 4 = 10). Row text sits `--ui-pad-row` (12) in, at
  least the row's radius, so it never runs into the curve. A group label,
  a search row or a status line starts its text where the rows' text does.
  A pill row on a short row puts the text inside the round end and reads
  cramped however much padding it gets.
- **Inputs**: same height ladder as buttons so they line up in rows. Label
  4–8px above the field; help/error text 4–6px below.
- **Optical padding**: a control's text sits the same distance in on every
  field in a form (14px). A trailing chevron or icon carries air inside its
  own box, so the padding after it is smaller (10px) to read as even.
- **Lists/tables**: row padding consistent, `py-2.5`–`py-3` for dense UI,
  `py-4` for comfortable. Dividers OR spacing — using both is redundant.
- **Connectors (steppers, timelines, pipelines)**: a line between two nodes
  stops one fixed gap short of each node's outermost edge, the same at both
  ends and in every state. So nothing state-driven grows past a node toward
  its line: a running or selected node changes its own fill and edge, not a
  ring or halo outside it, and a selection tint sits behind the text, never
  around the node the line meets. A corner badge may hang off a node only
  away from the line's axis. A moving highlight is the line itself
  brightening (tint the line, move a brighter stretch of it), never a
  second stroke laid over a grey one.

## 6. Breathing room failures to hunt

- Text touching container edges (missing padding on mobile).
- Buttons/badges where the text nearly touches the border — a pill with
  `px-2` at 14px text is always cramped; start at `px-3`
  (`--ui-pad-tag`: half the pill's height).
- Headings glued to the content above them instead of below (the 2:1 rule
  inverted).
- Full-bleed sections whose *content* forgot the gutter.
- Elements centered vertically in a taller sibling when they should be
  top-aligned (multi-line text next to an avatar or checkbox: align to the
  first line, e.g. `items-start` + small top offset).

## 7. Responsive collapse

- **Components adapt to their container, not the viewport.** A component
  that can sit in a narrow pane (a list, a card, a form row) makes itself a
  size container and folds with `@container`; viewport `@media` is for page
  chrome. A container query can't style its own container, so set the
  variables on its child. A grid's columns are `minmax(0, 1fr)`, never a
  bare `auto` that grows to the widest content, and nothing has a fixed
  width or `min-width` wider than a 320px phone.
- **On phones:** primary actions are 40px+ to the touch (grow a smaller
  control's hit area with a pseudo-element on `pointer: coarse`, not its
  look) and within the thumb's reach, spanning the card when stacked;
  prose tables stack, data tables scroll in their own box with the first
  column pinned; sheets, dialogs and drawers come up from the bottom, clear
  of `env(safe-area-inset-bottom)`; a bottom bar spans the width with its
  items sharing it evenly. A row of controls that can't fit wraps whole
  controls or scrolls inside its own track, never one word per line.

- Multi-column grids collapse in content order — verify the order still makes
  sense stacked. `lg:grid-cols-2` with a media-right layout often needs
  `order-first` on the media at mobile.
- Reduce, don't remove: section padding halves on mobile, it doesn't vanish.
- Anything `sticky` on desktop should usually stop being sticky on short
  viewports (`lg:sticky` rather than `sticky`).
- Never let horizontal scroll leak: wide tables and code blocks get their own
  `overflow-x-auto` wrapper; the page body never scrolls sideways.
- A table of prose (states, props, options) stacks when its box is too
  narrow for its columns: each row becomes a block, its first cell the title
  and every other value under its column's name. Sideways scroll is for
  data a person compares across columns, never for sentences.

# Surfaces & depth

Goal: surfaces that read as physical without shouting. The strongest finding
from polished production UIs: **elevation comes from background steps and
quiet borders far more often than from heavy shadows.** In light mode a card
gets a soft line and a faint, wide shadow that read as one; nothing heavier
unless it floats.

## 1. Page vs. surface

The page background and card surface are different values — always. One small
step apart:

- Light mode: page is a soft gray (~97% lightness), cards are white. The card
  "pops" with no border or shadow at all.
- Dark mode: **inverted** — cards are *lighter* than the page
  (e.g. page L≈0.15, card L≈0.20 in OKLCH). **No shadows in dark mode,
  anywhere**: the lightness step and the edges are the elevation. Every
  shadow is `light-dark(<light value>, transparent)`.

If the project renders cards on a same-color background with only a shadow,
consider stepping the page background instead — it's calmer and works in dark
mode for free.

## 2. Borders

- **Edges are one flat hairline, drawn as a shadow.** Fields, menus,
  panels, separators and chips take `--ui-hairline` (1px, at a faint
  alpha: a half pixel drew fuzzy on rounded corners) as a
  `box-shadow`: a ring for a box (`var(--ui-edge)`, or
  `inset 0 0 0 var(--ui-hairline) <color>`), an inset line on one side for
  a divider (`inset 0 var(--ui-hairline) 0 <color>` on top,
  `calc(var(--ui-hairline) * -1)` for the bottom). **Never `border`**: a
  border takes layout space and snaps differently from the rings beside
  it, so one box's line drifts from the next. Never a literal `1px` for
  an edge; always the token. No
  glints, lighter top edges or glassy double rings: a plain line fits any
  product the component lands in. The exceptions are deliberate: a
  focused field's edge is 1px (the weight change is part of the focus
  cue), and forced-colors outlines stay 1px so they never vanish.
- **Page layout gets no borders.** Headers, sections, hero bands and
  gallery tiles separate with space and a change of surface, not lines.
- **One neutral edge for every line.** A card's frame, a field, a button,
  a switch's track, a table's row rules, a separator, a key cap: all
  `--ui-border-strong` (drawn by `--ui-edge`; `--ui-border` is the same
  value), one solid neutral at the one hairline weight. Two
  grays side by side read as two thicknesses, a fainter line looks
  thinner, so there is no lighter "inner" tier: a box's insides and its
  frame are the same line. Depth comes from the shadow under a card, not
  from a darker outline.

  Beyond that one:
  - `--ui-field-focus`: a hovered, selected or focused control (or the one
    selected box in a set, like the current plan).
  - A colored edge, only for status or a component's own hue:
    `--ui-border-accent`, `-success`, `-warning`, `-danger`, or a hue (a
    phase, a brand) mixed at `--ui-border-tint`.

  Nothing else, and no exceptions at rest: a checkbox, a radio, a button
  and a card all rest on the same edge. No hand-picked alphas, no
  `color-mix` of the text color for an edge, no second gray: two neutral
  edges that look almost the same read as two weights. The edge is solid,
  not the text color at an alpha: a see-through line comes out darker over
  the page than over a card or a field, so one token drew two lines.
- **One ring per edge.** `--ui-shadow-resting` and `--ui-shadow-floating`
  already carry the outline; don't add `--ui-edge` or another hairline ring
  next to them (`--ui-shadow-raised` has none: pair it with `--ui-edge`).
  Two rings stack into a heavier line, and an inset ring plus an outer one
  draws a double edge. Pick a real `border` or a ring
  `box-shadow` per box, never both.
- Hover states on bordered elements move one step up the set
  (strong → field-focus). Polished hover deltas are
  small — sometimes just a few percent.
- Dividers: either borders or spacing, not both. A fading hairline
  (`h-[var(--ui-hairline)] bg-gradient-to-r from-transparent via-border to-transparent`) is a
  refined divider for centered content.
- Collapse doubled borders in lists/grids: `border-b -mb-px` or
  `divide-y` instead of per-item borders.
- Gradient or animated borders are decoration for one or two hero elements
  per page, max. The robust technique is the concentric wrapper: outer
  element carries the gradient as background, inner element insets by 1px
  (`m-px`) with radius = outer − 1, opaque fill.

### Line weights

One weight for every line on the page, so nothing reads heavier by accident:

- **Edges and dividers: one hairline.** Box-shadow rings and inset lines at
  `--ui-hairline`, never `border` (it renders at twice the weight). Where a
  child's background would cover an inset line (opaque sticky cells, a
  tinted row), draw the line on a `::after` over the children instead.
  Exceptions: a focused field's 1px edge, forced-colors outlines, a 2px
  quote bar, and cut-out rings in the surface color around an overlapping
  mark (an avatar stack, a corner badge).
- **Icon strokes match the text they sit with**, in two tiers:
  `--ui-icon-stroke-regular` beside 400 text, `-medium` beside a 500
  label. No blends, no third tier (typography.md §6).
- **Label weight by role**, the same everywhere: buttons, chips, tabs and
  segments, nav destinations, table headers, field labels and badges at
  500; body, menu and palette rows, list rows (chats, pages, records), field
  values and meta text at 400. The current item in a list may step to 500.

`tests/consistency.test.mjs` fails on a `border` edge, a 1px ring outside
focus, a literal color, a literal `stroke-width` or a literal icon size
(typography.md §6, "Icon sizes") in any CSS module.

## 3. Shadows — when you do use them

- **An edge drawn outside the box needs room outside it.** A ring
  (`box-shadow: 0 0 0 var(--ui-hairline)`) is shaved off by any ancestor that clips at the
  box's own edge. Let the clip reach a hairline further
  (`clip-path: inset(-1px)`) rather than moving the ring inside, which makes
  that one panel a hairline narrower than its neighbors.
- **Light mode: the edge and a soft spread shadow read as one depth.**
  Three steps, each the outline ring plus a tight contact layer and a wide,
  very faint ambient one in the same ink, pulled in by a negative spread so
  it gathers under the box instead of washing sideways. The steps grow in
  reach, not in darkness at the edge. Dark mode has none (§1).
  - `--ui-shadow-resting`: cards and panels on the page. Not a box nested
    inside one: that takes `--ui-edge` alone.
  - `--ui-shadow-raised`: a popover, a featured card, a hovered card (with
    `--ui-edge` or a field-focus ring, since it carries no ring).
  - `--ui-shadow-floating`: menus, dialogs, toasts.

Never write a shadow in a component. Every blurred layer is one of the
tokens above or `--ui-shadow-control`; a ring or line is `--ui-hairline`,
the 1px focus edge, or `--ui-knockout` (the surface-colored ring that cuts
a status dot or a stacked avatar out of what it overlaps).
`tests/consistency.test.mjs` fails on anything else.

- **Every button sits on `--ui-shadow-control`**, filled or not: the
  resting shadow's soft spread at a control's scale, starting below the
  edge, so the bottom line of a button is the same line as its top. No
  contact shadow tight under the edge (it reads as a thicker bottom
  border) and no light line along the top. Pressing drops it: the control
  goes flat as it goes down.

- **Tabs and segmented controls share one look; only their semantics
  differ.** Both are a clear track inside a hairline edge
  (`--ui-track-edge`), with one flat pill under the chosen item
  (`--ui-track-pill`, no shadow, no edge) at `--ui-track-inset` from every
  side, the same label colors and the same glide between items. A chosen
  item in a track isn't lifted: it's marked. Pick by meaning (tabs switch
  panels, a segmented control picks a value), never by look.

- Light-mode ambient opacity lives at 3–16%: wide and faint, never a
  dark band. Dark mode has none (§1).
- Nothing dark touches an edge. A shadow layer that starts at the edge
  (a 1–2px contact shadow) makes the bottom line look thicker than the
  rest; every token's spread starts below it. Fields, which are typed into
  rather than pressed, get the hairline alone.
- Tinted shadows: replace gray with the brand hue at low alpha
  (`0 24px 64px -24px` in the accent color at ~25%) for hero elements —
  reads as glow, never as dirt.
- Don't transition box-shadow on hover; polished UIs change background or
  border color instead. Shadows are static.

### Opaque where things overlap

Anything that can sit on top of a neighbor (stacked avatars, a folded stack
of site icons, tiles in a group) has an opaque fill: the surface, with any
tint mixed into it, never a translucent color over whatever is behind. Dim
a state by fading the mark inside the tile, not the tile.

### Footers in a panel

Buttons sit the same inset from every edge of the panel, concentric with its
corner. Text in the footer (a step count, an error) lines up with the
content above it instead.

### Code

Syntax colors come from the accent and status text roles, mixed in oklch
between hues and in oklab toward the text color so they stay calm, each at
4.5:1 on the code background in both themes. Four hues at most (keywords,
strings, numbers, types); names stay in the text color, so code reads as
text with a few marks. Comments are upright, in the subtle text color.

### Scroll edges

A scroll region that clips content fades only the edge with more past it
(over `--ui-space-6`), never a hard line, and only while something is
actually hidden there. A region that follows new content keeps its
following edge crisp while it follows.

### Phase colors

An agent's working phases (reading, thinking, writing, a source's stages)
all take the accent: the phase's name and glyph say which one. Green and
red keep meaning done and failed, grey paused or stopped. One variable
carries the state, and every "now" element (the dot, the bar, the running
step) reads it. No hue blended from two status hues: a component that
wants its own color for a phase exposes a variable for the product to set.

### Few colors

Every text, icon and fill on a page comes from one short list, so a screen
reads as one system:

- **Text and icons:** `--ui-foreground`, `--ui-muted`, `--ui-subtle`, the
  four status text roles (`--ui-accent-text`, `-success-text`,
  `-warning-text`, `-danger-text`), and on a filled control `--ui-on-ink`,
  `--ui-on-ink-muted`, `--ui-on-accent`. Never the foreground color-mixed to
  a new strength (82%, 60%): pick the role a step up or down.
- **Fills:** `--ui-fill` and `--ui-fill-strong` (translucent), the same
  two opaque as `--ui-fill-solid` and `--ui-fill-strong-solid` where they
  must hide what's under them, and one tint per status hue
  (`--ui-accent-tint`, `-success-tint`, `-warning-tint`, `-danger-tint`)
  behind status text. Not a fourth gray at 3% or 7%, not a status tint at
  6, 8 or 12%.
- **Pressed and chosen** states may step past these (16% under a pressed
  row), but the same step everywhere.
- Exceptions, named: a chart's series, syntax colors in code, real brand
  marks, a theme builder's swatches.

`tests/consistency.test.mjs` fails on a text color mixed from the
foreground and on a hue blended from two status hues.

### A stored theme

Set it with an inline script in `<head>` before paint, on `<html
data-theme>`. Anything that shows the theme (a toggle's icon) draws from that
attribute in CSS until it hydrates, then carries the same state, so nothing
flips at hydration. A change crossfades the page quickly, with no wipe, and
switches at once with reduced motion.

### Color-scheme islands

A region shown in the other theme (a light preview on a dark page) sets
`data-theme`, not just `color-scheme`: a `light-dark()` token resolves where
it's declared, so only redeclaring the tokens there gives that region its own
shadows and tints.

### Real marks for real services

Show a service with its real mark, inline as SVG, unaltered, with its name
in text beside it; a letter tile only when there's no mark. Invented logos
for real-looking services read as fake.

## 4. Radius

- One scale, derived from a single knob where possible:
  `--radius: 10px` with sm/md/lg/xl as ±2–4px `calc()` offsets. Controls
  (buttons, chips, badges, pills) very often go fully round — `rounded-full`
  is the most-used radius in polished products.
- **Concentric rule, no exceptions: inner radius = outer radius − gap.** A
  card at 16px containing media inset by 4px gives the media 12px. A menu at
  14px with 6px padding gives its highlighted row 8px. Nested equal radii
  look wrong at the corners every time.
  - Write the relationship, not the result:
    `border-radius: calc(var(--ui-radius-md) - 6px)`, so changing the outer
    radius or the padding keeps them lined up.
  - A pill inside a pill lines up when the inner pill's height equals the
    outer pill's height minus twice the gap.
  - If the math lands under ~4px, change the padding or the outer radius;
    don't square the inner corner off.
  - On pseudo-element overlays use `border-radius: inherit`.
- Consistency beats taste: any radius not on the scale is a defect.
- **The radius says what a thing is.** Pick by role, never per component:

  | Role | Radius | Examples |
  |---|---|---|
  | Things you press | full | buttons, segmented controls, tabs, chips, a compact select in a toolbar |
  | Fields you type in or choose from | md | text field, select, combobox, date, phone, number, each code box |
  | Boxes, panels and cards | lg (24) | cards, tables, code blocks, diffs, menus, dialogs, command palette, drop zone, composer |
  | Anything nested | outer − gap | options in a list, a button inside a field |

  A select in a form is a field, so it matches the text field beside it; only
  the compact toolbar select is a pill, like the buttons around it. A list
  that opens from a field takes the field's radius, so the two read as one
  piece. A field that holds round buttons is the exception that follows the
  concentric rule: its radius is the button's radius plus the gap.

## 5. Color tokens & states

- **One small, fixed palette:** neutrals, ink, one accent, and three status
  tones (success, warning, danger). Nothing else gets a hue.
  - Tune each theme on its own. One value for both sits at a mid
    lightness and modest chroma: gray on white, dull on black. In each
    theme take the most saturated value that stays in sRGB at a lightness
    holding 3:1 for fills, lines and icons and 4.5:1 for text: on light,
    fills around oklch L 0.55–0.67 and text around 0.5; on dark, fills
    0.6–0.78 and text 0.74–0.85. Keep a hue deeper wherever white text sits
    on it (accent, danger). Tints are the hue at 10% (16% in dark): the `--ui-*-tint` tokens.
  - Ink (`--ui-ink`, the text color as a fill) is for the strongest action:
    primary buttons, send, tooltips.
  - Accent (`--ui-accent`) is a real accent: used sparingly, only where the
    eye must find an on or chosen state among neutrals. A switch that's on,
    a checked box or radio, the selected day, a slider's range (a chosen value,
    like a switch that's on). Progress bars, meters, borders and bands stay
    neutral. A knob (a switch or slider thumb) is the raised surface in light
    and a light gray in dark: a knob the color of the surface reads as a hole
    in a dark track. If two accented things compete on one screen, one of
    them shouldn't be.
  - Status tones only ever mean that: success, needs attention (warning,
    amber), failed (danger).
  - One quiet touch takes it too, since it only shows while you act:
    selected text, as a pale tint. The caret stays the text color.
- Derive interaction states instead of inventing colors:

```css
.row:hover  { background: color-mix(in srgb, var(--foreground) 6%, transparent); }
.row:active { background: color-mix(in srgb, var(--foreground) 10%, transparent); transition-duration: 0s; }
```

  Hover ~5–8% of foreground, active ~10% — and active applies instantly
  (release eases back).
- Semantic tokens (`--background`, `--surface`, `--muted`, `--border`,
  `--ring`, `--destructive`) beat raw palette references in components. If
  the project has semantic tokens, never bypass them with a raw hex.
- Dark mode adjustments that are commonly missed: destructive/error colors
  get *lighter and less saturated* in dark mode; low-alpha error tints need
  roughly double the alpha to stay visible.
- The "alert tint" trio for banners/badges of any status color:
  30%-alpha border + 10%-alpha fill + solid text in the status color.

## 6. Overlays, without blur

Blur is the most overused effect in generated UI. It smears text behind it,
costs a compositing layer per element, and dates fast. Default to none.

- Floating surfaces (menus, popovers, toasts, sheets) are opaque: the
  surface color, a hairline and a shadow. They read as above the page
  by shadow, not by frosting what's under them.
- Modal scrims: a plain dim, roughly black at 25–30% in light mode and
  50–55% in dark. No `backdrop-filter`.
- Motion never blurs: letters, words and icons enter and leave with opacity
  and a small translate, not `filter: blur()`.
- Edge fades on scrollable rows are masks, not blur:
  `mask-image: linear-gradient(to right, transparent, black 10%, black 90%, transparent)`.
- If a design truly needs glass (media behind a control), one layer per
  screen, never nested, and the text on it must pass contrast without it.

## 7. Headers

Default to a header that scrolls away with the page. A sticky bar costs
reading height on every screen for links people rarely need mid-page. Make a
header sticky only when it holds controls used while scrolling (a document
toolbar, filters). Then: the page's own background color, solid, no line
under it, no blur, and its height published as `--header-height` for
`scroll-padding-top` and sticky offsets below it.

## 8. Scrollbars & small chrome

- Custom scrollbars only inside app panes (chat, code, file trees): thin
  (4–8px), transparent track, subtle thumb with a hover step, plus the
  standard `scrollbar-width: thin; scrollbar-color: ...` for Firefox.
  Leave the page scrollbar alone.
- Hide-scrollbar utilities need all three properties
  (`::-webkit-scrollbar { display: none }`, `scrollbar-width: none`,
  `-ms-overflow-style: none`) and must only be used where scrolling is
  visually obvious without the bar.
- Decorative images: `pointer-events-none select-none draggable="false"`.
- Icons inherit `currentColor`; inside buttons, `[&_svg]:shrink-0` and
  pointer-events none. Keep one stroke weight across the icon set.

## 9. Restraint checklist

- Gradients, glows, conic borders, textures: at most one or two attention
  moments per screen. If every card glows, none does.
- No pure-black shadows at high opacity in light mode; in dark mode the
  one edge is a neutral a step lighter than the surface, never a dark gray.
- Elevation levels in the app: aim for three, not seven — resting, raised
  (dropdown/popover), overlay (modal). Each maps to one consistent
  surface + border + shadow combination.

# States

Goal: the interface has an answer for every moment — loading, empty, error,
disabled, success — not just the happy path with data. Users judge quality in
exactly these in-between moments.

Method: for every view, list its states (initial load, loaded, empty,
partial, error, slow, and for mutations: submitting, succeeded, failed).
Anything unhandled is a defect.

## 1. Loading

- **Under ~300ms, show nothing.** An indicator that flashes for 100ms reads
  as jank. Delay indicators: a 300ms delay on the indicator with opacity
  starting at 0, or equivalent logic in JS. Once it shows, keep it at least
  400ms, so it doesn't flicker off either. The work itself starts at once:
  only the showing waits. "Saving…" labels follow the same timing.
- **Avoid spinners.** Prefer a shimmer on the label that names the work (the
  text dims and a light band passes across it, a moving `mask-image`
  gradient). A spinner is a last resort for
  a tiny indeterminate wait with no label at all. A small icon that has to
  say "working" breathes in place. The one turning glyph allowed is a
  12–16px arc in the icon slot of a step that's running, in a list of steps
  whose words name the work; it holds still when paused and breathes with
  reduced motion.
- **Skeletons only for a known layout on page open.** Grey bars advertise
  slowness before anything is slow, so they wait behind the same 300ms delay
  as any busy state, and only where the loaded layout is known: one element
  per bone, sized to the line it stands for, and one viewport-fixed sheen
  shared by every bone, never a shimmer per element. Anywhere else, hold the
  space the result will take (a fixed slot or `min-height` matching the
  loaded layout) and put one shimmering line in it that names the work
  ("Loading messages…"), so nothing shifts when it lands and the wait reads
  as progress. Reduced motion: still.
- **Buttons that submit**: keep the label and its width; shimmer the label
  while pending instead of swapping it for a spinner. Ignore presses while
  pending (busy, not disabled, so focus stays). Re-enable on failure.
- Images: always reserve space (width/height or `aspect-ratio`) so nothing
  reflows on load. Optional nicety: fade the image in over `--ui-dur-fast` once loaded
  against the muted surface color.

## 2. Empty

An empty state is an onboarding surface, not an apology. Structure:

1. One-line statement of what would be here ("No projects yet").
2. One line of value or guidance ("Projects group your deployments and
   environments").
3. The primary action to fix it (a real button: "Create project") — when an
   action exists.

Keep it visually quiet: one illustration or none, centered in the space the
content would occupy, `text-balance` on the copy. Never an icon sitting in a
rounded square or circle; that reads as an app icon, not as empty space.
Illustrations are clean isometric line drawings on a 2:1 grid (slopes stay
crisp) that clearly show their subject, one stroke weight across the set
(the icon set's medium) with round caps and mitered joins. Corners are
crisp: at most about a pixel of easing, never rounded off; only what is
round in life is round (a lens, a cord, a binder ring, a slot). Parts that
touch in life touch exactly: a hinge is a shared edge, a handle starts on
its rim, a stacked card lies on the one below. Text on a drawn card is
filled bars, not lines. Faces are opaque, in two tones at most: the surface and
one soft fill of the text color mixed into it, all from tokens so they read
in light and dark. Few lines, and large enough to be the focal point: about
200px in a panel, 260px on a page. Never leave a
bare "No data" / "0 results" string, and never render an empty table header
row with nothing under it.

Search/filter empties are different from true empties: say what was searched
("No results for 'foo'") and offer a reset ("Clear filters").

## 3. Error

- Field-level validation errors: adjacent to the field (4–6px below), red
  text at readable size (not 10px), the field border shifts to the error
  color, and the message says how to fix it — "Password needs 8+ characters",
  not "Invalid input". Set `aria-invalid` and link the message with
  `aria-describedby`.
- Validate on blur or submit, not on every keystroke while the user is still
  typing their first attempt. Clear the error as soon as the fix is made.
- Mutation failures: keep the user's input, say what happened and what to do,
  offer retry. Never silently swallow a failed action — a button that does
  nothing on failure is the worst state in this file.
- Full-view errors get the empty-state structure: what broke, one line of
  context, a retry action.

## 4. Disabled

- Disabled is a last resort; prefer enabled-with-validation-message so the
  user learns *why*. When genuinely disabled: reduced opacity (~50%) +
  `cursor: default` on the wrapper (disabled elements don't fire cursor),
  and never remove the element from tab order silently if a tooltip explains
  the why.
- Don't animate disabled elements' hover states — no hover response is part
  of the affordance.

## 5. Success & feedback

- Every action gets an acknowledgment within 100ms — even if it's just the
  pressed state. For mutations: optimistic UI where safe (toggle, like,
  rename), pending state where not (payment, delete).
- Toasts: enter with a small rise + fade (~200ms), auto-dismiss after
  4–6 seconds (longer if there's an action in the toast), dismissible, stack
  from one consistent corner. Success toasts are short ("Saved"); don't
  toast things the user can already see changed on screen.
- Inline confirmation beats a toast when the change is visible in place — a
  brief highlight (background tint fading out over ~1s) on the row that just
  changed is often better feedback than a corner notification.

## 6. Destructive actions

- Confirm before irreversible deletes — but make the dialog specific: name
  the thing being deleted, and make the confirming button say the verb
  ("Delete project", not "OK"). The destructive button gets the danger
  treatment; the safe action is the default focus.
- After delete, animate the removal (~200ms fade/collapse) so the list
  visibly changes rather than snapping.

## 7. Focus & keyboard (verify here even though foundations set it up)

- Every interactive element shows a visible `:focus-visible` state.
- Dialogs trap focus, return it to the trigger on close, close on Escape.
- No positive `tabindex` anywhere; tab order follows visual order.

# Motion & microinteractions

Goal: the interface responds to the user. Motion confirms actions, connects
states, and directs attention — it never performs for its own sake. Everything
here is CSS-first; do not add an animation library.

## The principle: least motion that keeps context

Every change of state animates, so the user never loses track of what became
what. But it animates with the least motion that gets there:

- **Only what differs moves.** If a property is the same in both states, it
  stays still. A field turning invalid changes its edge color and its message;
  nothing else.
- **Shortest distance, shortest duration that still reads.** Rises are 2–4px,
  scale stays within 0.97–1.03, and if the change reads at 150ms it doesn't
  get 300.
- **Move or morph the element that's already there** into its new place
  rather than fading one out and another in. Crossfade only when the two
  states have no spatial relationship (a hint trading places with an error,
  one panel replacing another).
- **No decorative secondary motion** riding along with the real one, and no
  stagger unless it shows an order the user should read (words arriving in
  reading order, yes; a row of chips, no).

## 1. Tokens: one curve per job

Define (or reuse) the motion vocabulary before writing any animation.
Scattered literal values are how motion languages fall apart. UI Polish names
curves after the job they do, not their shape, and pairs each with the
duration it was designed for. These are the roles in `tokens.css`:

```css
:root {
  /* Ladder for fades, enters, exits and height. */
  --ui-dur-instant: 120ms;  /* color feedback; small things leaving */
  --ui-dur-fast:    160ms;  /* small things arriving; blocks leaving */
  --ui-dur-base:    200ms;  /* blocks arriving; height closing; strokes drawing */
  --ui-dur-panel:   280ms;  /* height opening */

  --ui-ease-enter:    cubic-bezier(0.2, 1, 0.3, 1);    /* arriving: answers at once, short settle */
  --ui-ease-exit:     cubic-bezier(0.4, 0, 1, 1);      /* leaving: accelerates away, never lingers */
  --ui-ease-expand:   cubic-bezier(0.32, 0.72, 0, 1);  /* height opening: prompt push, firm stop */
  --ui-ease-collapse: cubic-bezier(0.4, 0, 0.2, 1);    /* height closing: eases in, folds, no tail */
  --ui-ease-color:  cubic-bezier(0.2, 0, 0, 1);      /* color/opacity feedback in place */
  --ui-ease-draw:   cubic-bezier(0.35, 0, 0.15, 1);  /* SVG strokes drawing on */
  --ui-ease-pulse:  cubic-bezier(0.37, 0, 0.63, 1);  /* breathing loops, with `alternate` */

  /* Springs: a little underdamped, sampled into linear() over their own duration. */
  --ui-dur-move: 380ms;  --ui-ease-move: linear(0, 0.0174, ...);  /* k 484, damping 0.72 */
  --ui-dur-snap: 240ms;  --ui-ease-snap: linear(0, 0.0228, ...);  /* k 1156, damping 0.8 */
  --ui-dur-glide: 320ms; --ui-ease-glide: linear(0, 0.0319, ...); /* k 484, critically damped */

  --ui-dur-pulse: 700ms;   /* half a breathing cycle */
  --ui-dur-sweep: 2400ms;  /* one pass of a light band, `linear` */
}
```

Which curve for which job, and why:

| Job | Curve | Duration | Why this shape |
|---|---|---|---|
| Something arrives: popover, menu, tooltip, toast, row, message | `--ui-ease-enter` | fast (small) or base (block) | Over 90% there by a third of the time, so it answers the click, with a shorter settle than quint-out so it reads as done sooner. Still a gentler first frame than expo-out, so opacity doesn't pop. |
| Something leaves | `--ui-ease-exit` | one step shorter than its entrance | Starts gently and accelerates out, so the eye lets go and it never hangs half-visible. |
| Something moves from where it was to where it is: FLIP, tab pill, toast stack, text morph letters and width | `--ui-ease-move` | `--ui-dur-move` | A spring with a little give (stiffness 484, damping ratio 0.72): it passes its target by about 4% and settles back, so things that move land with a touch of life instead of stopping dead. Interrupting and restarting from the current value looks continuous, which a cubic-bezier can't promise. |
| Small hops: press release, a highlight moving one row, a height that retargets as you type | `--ui-ease-snap` | `--ui-dur-snap` | Stiffer (1156, damping 0.8): about 1.5% past, then back, all in 240ms. Short distances on a long spring look floaty. |
| What a track or a count holds: a pill inside its group, a sheet against an edge, a height, a number counting to its value | `--ui-ease-glide` | `--ui-dur-glide` | The move spring without the give (critically damped): it lands without passing its target, so nothing swings past the end of its track or counts below where it stops. |
| Height opening (`grid-template-rows`) | `--ui-ease-expand` | `--ui-dur-panel` | Starts moving at once, so content below is pushed promptly, and brakes firmly instead of crawling. Not a spring: height drives reflow, so it should finish decisively. |
| Height closing | `--ui-ease-collapse` | `--ui-dur-base` | Eases in while the content fades, then folds and stops with no tail. On the expand curve a close loses its content in the first frames and then crawls shut, which reads as a jump and a drag. |
| Hover, highlight, selected, edge and tint changes; opacity as feedback | `--ui-ease-color` | `--ui-dur-instant` | Responds on the first frame with no travel to feel. |
| A stroke drawing on (`stroke-dashoffset`) | `--ui-ease-draw` | `--ui-dur-base` | A brief pen-down acceleration, then a long deceleration into the end point, like a hand. |
| Breathing loops (a working dot) | `--ui-ease-pulse` with `alternate` | `--ui-dur-pulse` each way | Sine in-out: smooth at both turns, no visible snap. |
| Light band sweeping across text | `linear` | `--ui-dur-sweep` | Constant travel; there are no ends to ease. Same for scroll-driven animation (scroll position is already the easing). |

Usage rules:

- **The spring tokens only mean what they say at their own duration.** A
  `linear()` spring sampled over 400ms and played over 600ms is a softer
  spring, not a slower one. Always pair `--ui-ease-move` with `--ui-dur-move`
  and `--ui-ease-snap` with `--ui-dur-snap`.
- **Exits are faster than entrances**, one step down the ladder. Appearing
  should feel gentle; disappearing, decisive.
- **Never use the enter curve for an exit.** Decelerating out leaves a ghost
  hanging at low opacity.
- **A crossfade in one cell offsets its halves:** the leaving layer drops on
  `--ui-dur-instant` with `--ui-ease-color` (the exit curve would hold it
  on top of the arriving one), and the arriving one enters on
  `--ui-ease-enter` half a beat later, so the two never read on top of each
  other.
- Overshoot stays small and belongs to things that move: the two springs pass
  their target by 4% and 1.5% and settle. Never on opacity or on height (a
  box that grows past its content shows a gap), and the bounce curve only
  for a thumb landing on a stop.
- A quiet default worth setting once if the project has no motion language:
  `* { transition-timing-function: var(--ui-ease-color); }`, so every
  transition inherits a designed curve instead of browser `ease`.

Springs are generated, not hand-written: simulate `x'' = -k(x - 1) - c x'`
from rest, sample it evenly over the duration (25–35 points, 4 decimals),
force the last point to 1, and write the stiffness, damping and duration next
to the token.

## 2. Hover & press: restraint is the signature

**Hover changes color only.** No scale, no lift, no shadow transitions on
controls when the pointer arrives.

- Primary buttons: shift background one step, e.g.
  `color-mix(in oklch, var(--ui-accent) 88%, var(--ui-surface))`, or a small
  alpha ramp (rest 8% → hover 12% → active 16% of foreground). Color
  transition only, `--ui-dur-instant` on `--ui-ease-color`.
- **Press may scale, a little.** `scale: var(--ui-scale-press)` (0.985) on `:active`, applied with
  `transition-duration: var(--ui-dur-press)` (80ms), so the press lands at once
  without reading as a cut, released back on
  `--ui-ease-snap` over `--ui-dur-snap`. Never below 0.97, never on hover, never on
  `[aria-haspopup]` triggers (the menu opens from a shrunken anchor), and
  never on disabled or busy controls.
- Press color goes down in the same 80ms; the release eases back:

```css
.button { transition: background-color var(--ui-dur-instant) var(--ui-ease-color), scale var(--ui-dur-snap) var(--ui-ease-snap); }
.button:active { scale: var(--ui-scale-press); transition-duration: var(--ui-dur-press); }
```

- Links/nav: `hover:opacity-80` or one color step. In grouped nav/menus,
  dim siblings instead of highlighting the target:
  `group-hover:opacity-50 hover:!opacity-100` — the group recedes, the
  hovered item holds.
- The arrow nudge is the classic link affordance:
  `group-hover:translate-x-0.5` on a trailing arrow icon, ~150–200ms,
  `transform` only.
- **Scale never moves more than 3%.** Anything from `0.97` to `1.03`, in any
  state: press, enter, exit, icon swaps, stacked layers (3% per layer). A
  bigger scale reads as a toy. Images inside cards may go to `1.03` on hover,
  `--ui-dur-panel` on `--ui-ease-enter`, inside `overflow: hidden`; never
  scale text on hover.
- Focus appears instantly — never transition focus styles.
- Kill the mobile tap flash once globally:
  `html { -webkit-tap-highlight-color: transparent; }` and make sure
  `:active` states exist to replace it.

## 3. Enter & reveal

Grammar for on-scroll or on-mount entrances:

- **Blocks**: `opacity: 0; translate: 0 4px` → settled, `--ui-dur-base` on
  `--ui-ease-enter`. Popovers and cards may use `scale(0.97)` instead of a
  rise; not both.
- **Things that float in** (popovers, menus, list popups, tooltips, toasts)
  also clear a 2px blur on the same timing: `filter: blur(2px)` → `none`.
  It reads as coming into focus instead of switching on, and it hides the
  first frame's half-drawn edges. Only on small surfaces that appear and
  leave; never on long lists, page sections or anything that scrolls. Rises stay at 2–4px for UI (on-scroll page sections may go
  to 8px over `--ui-dur-panel`); long travel reads as theatrical.
- **Never animate reading content from opacity 0 without a fallback.** If JS
  fails or the element is above the fold, text must be visible. Set the final
  state when reduced motion is on, fire reveals for above-the-fold content on
  mount, and disconnect observers after first fire (content does not
  re-animate on scroll-back).
- **Stagger** only when it shows an order the user reads (words in reading
  order, steps in sequence). Then 60–100ms between siblings, capped (after ~6
  items, bring remaining delays to 0). A group with no order arrives as one.
  Character-level stagger, if ever, is ~10–15ms.
- **Popovers/dropdowns/tooltips**: `opacity 0 → 1` + `scale(0.97 → 1)` with
  `transform-origin` at the trigger side, `--ui-dur-fast` on `--ui-ease-enter`
  in, `--ui-dur-instant` on `--ui-ease-exit` out.
- Native `<dialog>`/popover animation without JS:

```css
dialog { transition: opacity var(--ui-dur-instant) var(--ui-ease-exit), display var(--ui-dur-instant) allow-discrete, overlay var(--ui-dur-instant) allow-discrete; opacity: 0; }
dialog[open] { opacity: 1; transition-duration: var(--ui-dur-fast); transition-timing-function: var(--ui-ease-enter); @starting-style { opacity: 0; } }
```

- IntersectionObserver reveals: threshold ~0.15–0.25 for sections, plus a
  negative bottom `rootMargin` (e.g. `0px 0px -10% 0px`) so elements reveal
  after actually entering, not at the pixel edge. Use one shared observer,
  not one per element.

## 4. Expanding content (the auto-height problem)

Animate `grid-template-rows`, not `height`/`max-height`:

```html
<div class="grid transition-[grid-template-rows] duration-[var(--ui-dur-panel)] ease-[var(--ui-ease-expand)]
            data-[open=true]:grid-rows-[1fr] grid-rows-[0fr]">
  <div class="overflow-hidden"><!-- content --></div>
</div>
```

The `overflow-hidden` must be on the inner element. Open over `--ui-dur-panel`
on `--ui-ease-collapse`; close over `--ui-dur-base` on `--ui-ease-collapse`.
Pair with opacity: content fades in (`--ui-dur-fast`, `--ui-ease-enter`) half
a beat *after* the box starts opening, and fades out (`--ui-dur-instant`) on
`--ui-ease-exit` as it closes: the collapse barely moves for its first frames,
so the text is gone before the edge reaches it, and the fold that follows is
quick and has no slow tail. When one panel closes because another opens (an accordion that
keeps one open), close it on the opening's duration and curve, so the two
heights trade off exactly and what's below holds still.

**Removing an item from a list** (a file, an attachment, a dismissed alert,
a toast) is the same collapse, never its own curve: the item fades out over
`--ui-dur-instant` on `--ui-ease-exit`, starting on the press, and its space
closes over `--ui-dur-base` on `--ui-ease-collapse` once the fade is done
(delay it by `--ui-dur-instant`). A close that starts with the fade crushes
an item that's still visible and drags everything below up past it. Nothing slides
sideways or shrinks.
Never close a height on `--ui-ease-exit`: an accelerating collapse starts
slow and lands hard, so what's below drifts and then jumps.

### Dialogs enter like popovers

At block size: opacity on `--ui-dur-fast`, scale 0.97 and a 2px blur
clearing on `--ui-dur-base`, all on `--ui-ease-enter`. No spring: nothing
travels. On a phone it docks at the bottom and rises from its bottom edge.

### Numbers that change in place

Never animate their width or position. Keep the box a fixed width, swap only
the digits that changed (transform and opacity), and nothing on first paint.
A number fed faster than it can be read (a count ticking forty times a
second) shows its latest value at most about five times a second, and its
last value always lands; a rate or an estimate settles once a second. The
same goes for a name swapping in place: pace it, or it never finishes
fading in.
A pill that marks one item in a row (the current page, the chosen tab) is
sized and placed from the same variables as the item boxes, with odd-width
items like arrows counted explicitly, so the number sits dead center in it.

### A status that changes in place keeps its box

Every label a status can show sits in one grid cell, so the box holds the
widest and nothing beside it moves. Its color changes over `--ui-dur-base`
on `--ui-ease-color`, and the label swaps as an offset crossfade. A spinner,
where one is earned (a step running in a list of steps), is a 16px glyph in
the slot of the icon it replaces, never a new element beside it; with reduced
motion it stops turning and breathes.

### One tooltip per rail or toolbar

Triggers in a row share a single tooltip. Once it's on screen it glides to
the next trigger (glide spring, no overshoot), eases its width to the new
label and crossfades the label with a small shift in the direction of travel.
It never re-enters while you move along the row, and the first one still
waits for the delay.

### A sidebar folds to its rail without moving an icon

Lay the rows out from the collapsed rail: each icon sits where it would be
centered in the rail's capsule (content inset = (capsule − icon) / 2), so
when the width changes no icon moves. The width eases like a height
(`--ui-ease-expand` over `--ui-dur-panel` opening, `--ui-ease-collapse` over
`--ui-dur-base` closing). Rows narrow with it, so the current row's pill
becomes the rail's capsule; their labels leave on `--ui-dur-instant` in the
first frames and come back half a beat after it starts opening, so no word
is read while it moves. What has no icon (headings, a history list) is held
at the open width so it never rewraps, fades first, and goes `inert`. With
reduced motion it switches at once.

### A field that waits folds into its answer

A field that can't take input while work runs (a one-time code being
checked) may fold into a pill the same height on the glide spring, and that
pill carries the result: a success tint and a check, or it opens back into
the field with the input intact and the error below. Lock it at once, fold
only after the 300ms busy delay, and keep the space it held so nothing below
moves. Focus waits on the group while it's folded and returns to the field
only if it was there before.

### Results land, they don't bounce

A change that shows an outcome (a card resizing to its result, a link
turning into a check) glides into place on `--ui-ease-glide`. Overshoot is
for things a person moves or toggles; on a result it reads as a wobble.

### Views that page

A view that pages (calendar months, carousel steps) slides 16–24px in the
direction of travel on the glide spring while the old one fades out first
and the new one fades in just after, so the two never sit on top of each
other. Clip it inside its panel and fade the clip edge through the panel's
padding with a mask; nothing at rest sits in the fade. Interrupted midway,
a view leaves from where it visibly is.

### Nothing below moves while content streams

Reserve the space before content arrives. A thinking window, a progress
area or a result panel opens once, to its full height, and stays that size
while text streams into it; it doesn't grow line by line and push what's
below. A label whose width changes glides to the new width (the glide spring)
so what sits beside it slides rather than jumps, and at rest has no fixed
width, so a late webfont can't cut it.

A streaming log follows its end only while the reader is there. Once they
scroll up it holds still, keeps what they're reading pinned (a stage
switching underneath counts as moving it), and offers one floating "3 new
lines" control back to the end.

### Reveals with no hard edge

Opening height clips its content, and a solid box swept by the clip edge
shows a hard line. While the space opens, the content stays faint and soft:
fade, a blur clearing and a small scale from the top, all over the full
opening; and fade the bottom few pixels of the clip with a mask. Done right,
you can't tell how it animated.

### Page transitions

A straight crossfade, both pages visible the whole time (`plus-lighter` so
there's no dip halfway), and never a blank frame. Chrome that doesn't change
between pages (a docs sidebar) gets its own `view-transition-name` and stays
still, and when it appears or leaves with the page it moves a short way (24px)
from its own edge as it fades. Opening an item from an overview (a tile into
its page) shares a name between the two, so the item glides into place on the
glide spring, no overshoot, while everything else fades.

## 5. Micro-recipes

- **Disclosure chevron**: which motion follows from its axis. At the end of a
  row or under a list ("Show all"), the content opens below it, so it points
  down and turns up: render the icon for the state
  (`<Icon name={open ? "chevron-up" : "chevron-down"} />`) and let the icon
  set morph the arms; no CSS rotate. Trailing or leading a short inline
  label ("4 sources ›", "Thought for 8s ›"), it points right and opens
  downward: that changes axis, and a morph between the two shapes reads as
  the arrow coming apart, so it turns a quarter turn instead
  (`<Icon name="chevron-right" data-turn={open ? "open" : ""} />`), over
  `--ui-dur-base` on `--ui-ease-glide`, never with overshoot. Either way it
  rests in `--ui-muted` and goes to `--ui-foreground` while its trigger is
  hovered, a color change only (`--ui-dur-instant`, `--ui-ease-color`). If
  the open state can change between mount and first paint (opened by the
  URL), remount the icon after first paint (`key={String(ready)}`) so it
  shows the open shape instead of moving into it.
- **Underline that doesn't shift layout**: a `scaleX(0 → 1)` pseudo-element
  with `transform-origin: left`, `--ui-dur-base` on `--ui-ease-enter` — not
  `text-decoration` toggling.
- **Toast**: enter with fade + a 4px rise, `--ui-dur-base` on
  `--ui-ease-enter`; exit with fade + a 4px drop, `--ui-dur-fast` on
  `--ui-ease-exit`; the stack moves on `--ui-ease-move`. Defer unmount until
  the exit finishes.
- **Text morph** (a label changing in place: Copy → Copied, Save → Saving…):
  match whole words the two labels share first, so a shared word slides as
  one piece ("Send invitation" → "Invitation sent"). Then pair leftover
  letters only within the same gap between those words, in order, so no
  letter ever crosses a word. Paired letters slide from their old x to the
  new one (FLIP: measure, invert with `translateX`, play to zero on
  `--ui-ease-move` over `--ui-dur-move`). Letters that leave fade and lift
  out in `--ui-dur-instant` on `--ui-ease-exit`; letters that arrive fade in
  on the move spring with ~15ms stagger. No blur.
  The wrapper's width animates on the same spring so the container grows into
  the new word. This is one sanctioned exception to "never animate width": it
  is one inline box, and the alternative is a jump. Screen readers get the
  full label from a visually hidden copy; the letters are `aria-hidden`.
- **Content swap in place** (theme, route, a hint becoming an error):
  crossfade two stacked layers in the same grid cell rather than animating
  one element through empty. The cell holds the taller of the two, so
  nothing below moves.
- **Shimmer** (work in progress): the label dims and a light band sweeps
  across it, `linear` over `--ui-dur-sweep` (`sweep` in `registry/text`).
  Never a fast blink, and still under reduced motion.
- **Marquee** (if one exists): duplicate content, animate `translateX(0 → -50%)`
  linear, mask the edges with a gradient, pause on hover, and pause offscreen.
- Derive related delays from durations instead of hardcoding: if a line draws
  in 600ms and its endpoint dot fades in 300ms, delay the dot by 300ms so it
  lands as the line arrives.

## 6. Performance discipline

- Animate **`transform`, `opacity`, `filter`, and colors only.** Never
  `width`/`height`/`top`/`left`/`margin` (exception: `grid-template-rows`
  above). Never `transition: all` on anything that moves.
- `will-change: transform` only on elements that continuously animate
  (marquee tracks, pinned sections) — a handful per page, never globally.
- Scroll-linked effects: `requestAnimationFrame`-coalesced with a passive
  listener writing a transform, or CSS `animation-timeline: scroll()/view()`
  behind `@supports (animation-timeline: scroll())` with a static fallback.
- Ambient/looping decoration (if kept at all): slow (3s+) and low-amplitude,
  paused when offscreen. Nothing loops fast enough to draw the eye while
  reading.

## 7. Reduced motion — non-negotiable

Every animation you introduce is guarded, and the guard produces the **end
state**, not a missing element:

```css
@media (prefers-reduced-motion: reduce) {
  .reveal { animation: none; opacity: 1; transform: none; }
  html { scroll-behavior: auto; }   /* often forgotten next to scroll-smooth */
}
```

- With utility classes, prefer a `motion-safe` variant for anything that moves.
- JS-driven animation branches on
  `matchMedia('(prefers-reduced-motion: reduce)').matches` and sets final
  values instantly. Reduced motion means *instant state change*, not *no
  state change*.
- Sub-200ms opacity/color transitions may remain — they are feedback, not
  motion.
- Colocate the reduce override with each animation it guards, so copies of
  the component keep the guard.

## 8. What NOT to do

- No entrance animation on every element — reveal groups/sections, not each
  paragraph.
- No re-triggering reveals on every scroll pass.
- No animated focus rings, no animated disabled states.
- No spring physics on functional UI (menus, accordions, inputs).
- No hover-dependent information (touch devices exist); hover only enhances.
- No motion added to an app that already has a motion language you haven't
  matched — extend theirs.

# Anti-patterns: the rules nobody writes down

Check these before anything cosmetic. They are defects that pass code review
because each one looks intentional in isolation. They cluster around one root
cause: **a signal is being sent that the interface can't honor.** A hover state
promises clickability; a spinner promises progress; a cursor promises action.
Every unhonored promise erodes trust in the ones that are honored.

## Affordance lies

- **No hover states on non-interactive elements.** A background tint, lift, or
  cursor change on a static card/row/box tells the user it's clickable. If
  clicking does nothing, remove the hover treatment — or make the element
  actually clickable. Corollary: every genuinely interactive element *must*
  have a hover and press treatment; the absence is the same lie inverted.
- **`cursor: pointer` only on things that act.** Not on static text, not on
  disabled controls, not on a whole card when only its button is interactive.
- **Never nest interactive in interactive.** A button inside a clickable card
  (or a link inside a link) is invalid HTML and ambiguous for every input
  method. Make the card's primary link cover the card (stretched-link
  pattern) and lift secondary actions above it with `position: relative`.
- **Hover must never be the only path to content or actions.** Touch devices
  can't hover; a delete button revealed only on hover is invisible on mobile
  and to keyboard users. Hover may *emphasize*; it may not *gate*.
- **`title` attributes are not tooltips.** They don't appear on touch, on
  focus, or reliably at all. If the information matters, render it.
- **Text that looks like a link but isn't** (accent color, underline) and
  links that look like plain text. The visual grammar of "this is
  interactive" must be exclusive to interactive elements.

## State-change honesty

- **Transitions belong on the base state, not the `:hover` block.** Declared
  only under `:hover`, the enter eases but the exit snaps. (The one sanctioned
  asymmetry is the reverse: `:active { transition-duration: 0s }` so presses
  read as instant while release eases back.)
- **State changes must not reflow.** Bolding the active tab, growing a border,
  or swapping to a wider label shifts every sibling. Reserve the space:
  pre-render the boldest/widest state invisibly, use an inset ring or a
  pre-allocated 2px transparent border, keep a button's label (and width)
  while it's busy and shimmer it rather than swapping it for a spinner.
- **Don't animate the focus ring, and never style bare `:focus` away.** Style
  `:focus-visible`; the ring appears instantly or keyboard users lose their
  place.
- **Hover styles need `@media (hover: hover)`** when they'd misbehave on
  touch — mobile browsers apply `:hover` sticky-on-tap, leaving cards "stuck"
  highlighted after a scroll-tap.
- **Selection must be visible.** A brand-tinted `::selection` below ~15–20%
  alpha is indistinguishable from the page — users can't see what they
  selected. Check the highlight against the background, not just the text
  against the highlight.

## Motion that betrays

- **Reveals fire once.** Content that re-animates on every scroll-back turns
  the page into a slideshow. Disconnect observers after first fire.
- **Don't make returning users watch the intro.** Entrance choreography on
  app chrome (nav, sidebars, dashboards users open 20× a day) taxes every
  single visit. Reserve staged entrances for genuinely first-run or
  marketing surfaces.
- **Nothing moves without a cause the user can name.** Ambient looping motion
  near reading content pulls the eye on every line. If asked "why did that
  move?", the answer must never be "to look alive."
- **Skeletons advertise slowness.** A page of grey bars says "this is slow"
  before anything has gone wrong. Hold the space the result will take, and
  shimmer the label that names the work inside it. Under ~300ms, show nothing
  at all — a flash of any indicator reads as jank, not responsiveness.
- **Spinners as the default loading state.** A spinner says "something" and
  nothing more. Shimmer the label that names the work;
  keep spinners for a tiny indeterminate wait with no label.

## Environmental blind spots

- **`100vh` is broken on mobile.** Browser chrome makes `100vh` taller than
  the visible screen; bottom-anchored content gets clipped. Use `100dvh`
  (layout that may resize) or `100svh` (stable minimum).
- **Fixed/sticky chrome must respect `env(safe-area-inset-*)`** — bottom bars
  and FABs collide with home indicators and notches without it.
- **Modals and drawers need `overscroll-behavior: contain`** or the page
  behind scroll-chains when the user hits the end of the panel.
- **Check every theme you ship.** A hardcoded `white`, a shadow tuned only
  for light mode, a gray border invisible on dark surfaces — every visual
  change is a two-theme change if two themes exist.
- **Zoom to 200%.** Fixed heights on text containers clip at zoom and on
  translation; use `min-height`. Never `user-scalable=no` or
  `maximum-scale=1`.

## Silent failures

- **Every async action needs a failure branch.** A `.then()` with no
  `.catch()` on clipboard/share/fetch means the button silently does nothing
  in the failure case — the most trust-destroying state an interface has.
  Feature-detect (`if (navigator.clipboard)`) and degrade visibly.
- **Icon-only state changes are inaudible.** A copy button whose icon flips
  to a checkmark tells screen-reader users nothing. Pair visual state changes
  with a `role="status"` live region ("Copied").
- **Disabled without explanation is a dead end.** Prefer enabled +
  validation message; if truly disabled, the *why* must be discoverable
  without hovering.

## Stacking & overflow debt

- **No z-index arms race.** `z-index: 9999` means the stacking model is
  broken. Keep a small documented ladder (dropdown 10, sticky 20, overlay 30,
  modal 40, toast 50) and create stacking contexts deliberately with
  `isolate`.
- **`overflow: hidden` as a fix is usually a symptom.** It silently clips
  focus rings, shadows, and dropdowns of every descendant. Find the actual
  overflowing element; prefer `overflow: clip` on the axis that needs it. (Truncation is the exception: its fade needs `hidden`, since `clip` isn't a scroll container.)
- **Scrollable regions need keyboard access.** An `overflow-x: auto` code
  block or table with no focusable content can't be scrolled by keyboard in
  all browsers — add `tabindex="0"` with an accessible name, or let content
  wrap instead.

- **Tracking that switches ligatures off.** Any `letter-spacing` on text,
  even −0.01em, turns off optional ligatures and contextual forms. The
  system face tracks itself by size; leave it at `normal` (typography).
- **Ellipsis, or a fade on text that fits.** Truncate with a fade at the
  end of the line, applied only while the text is actually cut; a fade on a
  label that fits reads as a rendering bug (typography).

## Audit heuristic

For each element ask two questions: *what does its styling promise?* and
*does interacting keep that promise?* Every mismatch — in either direction —
is a defect, and these mismatches outrank cosmetic findings: a
broken promise is worse than a missing flourish.

# Finished?

Run these against a screen or a component before calling it done. Every answer
should be yes. Each check names the rule file that explains it.

## Interaction

1. Does every element that acts have a hover, a press, and a visible
   `:focus-visible` state, and does nothing that doesn't act have any of them?
   (antipatterns)
2. Are transitions declared on the base state, so leaving a state eases as
   well as entering it? (antipatterns)
3. Is `:active` instant (`transition-duration: 0s`) with the release easing
   back? (motion)
4. Are hover styles behind `@media (hover: hover)`, and is hover never the only
   way to reach an action? (antipatterns)
5. Are touch targets at least 40×40px? (foundations)

## State

6. Does every async action have a pending state, a failure branch the user can
   see, and a way to retry? (states)
7. Does a button keep its label and width while busy (a shimmer on the
   label, not a spinner) and when its label changes? (states)
8. Is nothing shown for loads under ~300ms, and does a longer load shimmer the
   label that names the work inside space the result already holds? (states)
9. Do lists and tables have an empty state with a next step, not a bare "No
   data"? (states)
10. Do field errors appear on blur or submit (not while typing), say how to
    fix the problem, clear as soon as it's fixed, and not shift the layout?
    (states)
11. Is disabled the last resort, and when used, is the reason visible without
    hovering? (antipatterns)

## Motion

12. Does every state change carry the user from one state to the next, with
    the least motion that does it: only the properties that differ, the
    shortest travel and duration that still reads, the existing element
    moving into place rather than a fade out and in, and nothing that both
    states share moving at all? (motion)
13. Is everything that moves animating `transform`, `opacity`, `filter` or
    color only (or one of the named exceptions)? (motion)
14. Does each animation use the curve made for its job (enter, exit, move,
    snap, expand, color, draw, pulse) with that curve's duration, and do
    exits run shorter than entrances? (motion)
15. Under `prefers-reduced-motion: reduce`, does every animation jump to its
    end state instead of disappearing? (motion)
16. Can you name why each thing moves? Nothing loops or replays on its own.
    (antipatterns)

## Type, space and surface

17. Do headings use `text-wrap: balance`, paragraphs `text-wrap: pretty`, and
    changing numbers `tabular-nums`? (typography)
18. Is every size, line-height and weight a step from the type scale, text
    untracked (all-caps labels aside), and cut text faded only when it's
    actually cut? (typography)
19. Does text a person needs to read wrap instead of being cut, and does
    nothing below move while content streams in? (typography, motion)
20. Does every control in a row take its height from 32 / 40 / 48, composite
    controls measured whole? (spacing)
21. Is every spacing value on the scale, taken from the role for its
    relationship (`--ui-pad-*`, `--ui-gap-*`), with gaps inside a group at
    most half the gap between groups? (spacing)
22. Do nested radii follow inner = outer − gap, and is every radius on the
    scale? (surfaces)
23. Does it hold up in every theme the project ships, with no hardcoded white,
    black or light-only shadow? (surfaces)
24. Are mobile basics covered: `100dvh` not `100vh`, safe-area insets on fixed
    chrome, inputs at 16px or larger? (antipatterns, typography)

## Accessibility

25. Do icon-only controls have an accessible name, and do visual-only state
    changes (Copied, Saved) also reach a live region? (antipatterns)
26. Does text contrast stay at WCAG AA or better, and is the focus line
    clearly visible on every surface it lands on? (foundations)
