
/* GENERATED by packages/ai-patterns/scripts/build-design-project.mjs.
   Do not edit by hand — run `pnpm -F @elirobinson/ai-patterns build:design-project`.
   The aggregate the guidelines/ cards link to, pointing at the real
   component stylesheets rather than a copy of them. */
/* ==========================================================================
   Miltinson Design System — Design Tokens
   colors_and_type.css
   ==========================================================================
   Single source of truth for color, type, spacing, radii, shadow, motion.
   All values are exposed as CSS custom properties on :root.
   Semantic tokens reference base tokens — never hardcode values in components.

   This is the only copy. `design-system-docs/colors_and_type.css` is a symlink
   to this file, and `src/tokens.json` is generated from it by
   `scripts/build-tokens-json.mjs` — edit here, run the package build, and every
   derived form follows.

   --------------------------------------------------------------------------
   Three dials
   --------------------------------------------------------------------------
   This file is one of three layers, and they compose as three independent
   attributes on the root element:

     palette   data-palette="ember|slate"   palettes.css
     theme     data-theme="light|dark"      palettes.css + this file
     platform  data-platform="mobile"       mobile.css

   This file owns what none of them changes the *meaning* of: the neutral
   ramp, the surfaces built from it, type, space, radii, shadow, motion,
   targets, safe areas, `--scrim`, and the global element styles. The brand
   ramps and every semantic derived from them — `--accent*`, `--anchor*`,
   `--link*`, `--focus-ring` — moved to palettes.css, along with the status and
   chart families, which are palette-INdependent by design.

   Nothing in this file may reference `--signal-*` or `--anchor-*` directly.
   Those names exist under every palette and mean something different under
   each, so a reference here is an assertion that one brand is mounted.
   ========================================================================== */

/* --- Cascade layer -------------------------------------------------------
   One layer, declared before anything else so its position in the layer order
   is fixed by this statement rather than by wherever its first rule lands.
   Only the bare-element link rule near the bottom of this file lives in it;
   everything else here — every `:root` block included — stays unlayered and
   therefore still outranks anything a consumer writes inside a layer.

   The name is `base`, not a `ds-`prefixed one, and that is the whole design.
   Layer order is fixed by FIRST declaration, and the documented Tailwind v4
   wiring (see tailwind.css, "Usage") pulls in `tailwindcss` ahead of this
   file — which declares, in one statement: theme, base, components,
   utilities. A fresh name declared here would therefore be appended AFTER
   `utilities` and outrank every utility, which is issue #112 unfixed and just
   spelled differently. `base` is a name Tailwind has already ordered below
   `components` and `utilities`, so reusing it puts the rule exactly where it
   belongs. Without Tailwind, this statement declares `base` itself and it
   becomes the lowest-priority layer in the document, which is the same
   answer. link-cascade.test.mjs measures both arrangements in a browser.

   Note for anyone extending this comment: do not write a literal
   `at-import 'name'` anywhere in this file's prose. brand-manifest.mjs in
   @elirobinson/ai-patterns scans the raw text of every stylesheet the brand
   skill ships and follows each import it finds — comments included — and
   fails the build on one it cannot resolve on disk.
   ---------------------------------------------------------------------- */
@layer base;

/* --- Webfonts ------------------------------------------------------------ */
/* Geist (display + body sans) and JetBrains Mono (code + numerics),
   self-hosted from ./fonts/ — both are SIL OFL 1.1, licenses ship alongside
   the files. fonts.css is generated by scripts/build-fonts.mjs from the
   pinned @fontsource-variable packages; importing this file is enough to load
   the faces from your own origin. No request leaves the page.

   THE STRING FORM IS LOAD-BEARING. Not `url('./fonts.css')` — the bare
   string. Lightning CSS, which is Turbopack's CSS pipeline, INLINES the bare
   form (`at-import './x.css';`) and leaves the url() form
   (`at-import url('./x.css');`) standing as a literal @import in its output.
   Nothing else about the two spellings
   differs, and that one difference is issue #76: a surviving @import is then
   subject to the rule that an @import may not follow a rule, and a consumer's
   entry stylesheet always has rules ahead of this file —

     at-import 'tailwindcss'    <- v4 requires it first; it inlines its rules
     at-import '@elirobinson/tokens/tokens.css'

   — so the fonts import is stranded, dropped as invalid, and every @font-face
   goes with it. Measured in a Next 16.3.1 + Tailwind v4.3.3 consumer: 0
   @font-face rules in the built stylesheet with `url()`, 22 without it. It is
   silent, too. `next build` prints the warning to stderr and exits 0; only
   `next dev` fails loudly, with a 500. The two forms are interchangeable in
   every other respect, which is why this survived review for four releases.

   Being FIRST of the three imports is a SEPARATE, WEAKER invariant, and it is
   deliberately kept rather than relied on. It is not what fixes #76 — the
   string form alone fixes it with this import back below palettes.css, and
   this position alone does not fix it with `url()` restored; both were
   isolated against each other in that consumer. What the ordering buys is
   defence in depth if some future bundler stops inlining the string form too,
   at the cost of nothing: fonts.css declares nothing but @font-face, so
   hoisting it moves no declaration relative to any other.

   import-order.test.mjs asserts both, and is explicit about which one would
   have caught the bug. Note that the file is valid standalone under either
   spelling and in any order, which is why nothing in this package noticed. */
/* GENERATED by packages/tokens/scripts/build-fonts.mjs. Do not edit by hand.
   @font-face for the families tokens.css names, self-hosted from src/fonts/.
   Faces and unicode-range subsets come from the pinned @fontsource-variable
   packages; licenses ship alongside the files (SIL OFL 1.1). */

/* --- Geist — @fontsource-variable/geist ------------------------------ */

@font-face {
  font-family: 'Geist';
  font-style: normal;
  font-display: swap;
  font-weight: 100 900;
  src: url(./fonts/geist-cyrillic-ext-wght-normal.woff2) format('woff2-variations');
  unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}

@font-face {
  font-family: 'Geist';
  font-style: normal;
  font-display: swap;
  font-weight: 100 900;
  src: url(./fonts/geist-cyrillic-wght-normal.woff2) format('woff2-variations');
  unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}

@font-face {
  font-family: 'Geist';
  font-style: normal;
  font-display: swap;
  font-weight: 100 900;
  src: url(./fonts/geist-vietnamese-wght-normal.woff2) format('woff2-variations');
  unicode-range:
    U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301,
    U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}

@font-face {
  font-family: 'Geist';
  font-style: normal;
  font-display: swap;
  font-weight: 100 900;
  src: url(./fonts/geist-latin-ext-wght-normal.woff2) format('woff2-variations');
  unicode-range:
    U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329,
    U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F,
    U+A720-A7FF;
}

@font-face {
  font-family: 'Geist';
  font-style: normal;
  font-display: swap;
  font-weight: 100 900;
  src: url(./fonts/geist-latin-wght-normal.woff2) format('woff2-variations');
  unicode-range:
    U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
    U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

/* --- JetBrains Mono — @fontsource-variable/jetbrains-mono ------------------------------ */

@font-face {
  font-family: 'JetBrains Mono';
  font-style: normal;
  font-display: swap;
  font-weight: 100 800;
  src: url(./fonts/jetbrains-mono-cyrillic-ext-wght-normal.woff2) format('woff2-variations');
  unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}

@font-face {
  font-family: 'JetBrains Mono';
  font-style: normal;
  font-display: swap;
  font-weight: 100 800;
  src: url(./fonts/jetbrains-mono-cyrillic-wght-normal.woff2) format('woff2-variations');
  unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}

@font-face {
  font-family: 'JetBrains Mono';
  font-style: normal;
  font-display: swap;
  font-weight: 100 800;
  src: url(./fonts/jetbrains-mono-greek-wght-normal.woff2) format('woff2-variations');
  unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}

@font-face {
  font-family: 'JetBrains Mono';
  font-style: normal;
  font-display: swap;
  font-weight: 100 800;
  src: url(./fonts/jetbrains-mono-vietnamese-wght-normal.woff2) format('woff2-variations');
  unicode-range:
    U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301,
    U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}

@font-face {
  font-family: 'JetBrains Mono';
  font-style: normal;
  font-display: swap;
  font-weight: 100 800;
  src: url(./fonts/jetbrains-mono-latin-ext-wght-normal.woff2) format('woff2-variations');
  unicode-range:
    U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329,
    U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F,
    U+A720-A7FF;
}

@font-face {
  font-family: 'JetBrains Mono';
  font-style: normal;
  font-display: swap;
  font-weight: 100 800;
  src: url(./fonts/jetbrains-mono-latin-wght-normal.woff2) format('woff2-variations');
  unicode-range:
    U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
    U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}


/* --- The two sibling stylesheets -----------------------------------------
   Declaration order, not cascade layers — the section at the top of the file
   is the one about `@layer`.

   @imported, so both files' declarations land BEFORE this file's own
   `:root`. That is deliberate and it is also the one thing to know when
   editing either of them: at equal specificity the later rule wins, so a
   palette or platform block written as a bare `[data-*]` selector — (0,1,0),
   the same as `:root` — would silently lose to the declarations below.
   palettes.css owns `--n-h`/`--n-mult` for that reason, and mobile.css
   qualifies its selectors with `:root`. ---------------------------------- */
/* ==========================================================================
   Miltinson Design System — Design Tokens
   palettes.css — the palette layer
   ==========================================================================
   The brand dial, selected by `data-palette`. @imported by tokens.css
   alongside mobile.css.

   tokens.css used to hardcode one brand. It now owns the neutrals, the
   surfaces and the geometry; this file owns everything that changes when the
   brand does: the two colour ramps, the neutral hue and chroma the greys are
   mixed from, and every semantic token derived from them — `--accent*`,
   `--anchor*`, `--link*`, `--focus-ring`.

   Two families deliberately live here and are NOT on the dial: status and
   chart. They are declared once, in the two theme blocks, and no palette block
   touches them. That omission is the mechanism, not an oversight — see
   "Status" below for why a warning badge that changes colour with the brand is
   a bug rather than a feature.

   ---------------------------------------------------------------------------
   Flat blocks, not a matrix
   ---------------------------------------------------------------------------
   Every palette times two themes resolves out of flat blocks with no
   `!important`, no `@layer`, and no build step. Two blocks per palette, in
   this order:

     1. `:root`                                        ember      light  (0,1,0)
     2. `[data-theme='dark'], .dark`                    ember      dark   (0,1,0)
     3. `[data-palette='slate']`                        slate      light  (0,1,0)
     4. `[data-palette='slate'][data-theme='dark'],
         [data-palette='slate'].dark`                   slate      dark   (0,2,0)
     5. `[data-palette='miltinson']`                    miltinson  light  (0,1,0)
     6. `[data-palette='miltinson'][data-theme='dark'],
         [data-palette='miltinson'].dark`               miltinson  dark   (0,2,0)

   A palette's dark block is the only one of its pair that carries two compound
   conditions, so it is the only one with (0,2,0), and it outranks every other
   block wherever it applies. Every light block and the ember dark block tie at
   (0,1,0) and are decided by source order, which is why the order above is the
   order below and must stay that way.

   The consequence a reviewer has to check: on `slate` + `dark`, blocks 1, 2, 3
   and 4 all match, and the tie between 2 and 3 goes to 3 — the *later* one —
   which is slate's LIGHT block. So block 4 cannot be a thin diff over block 2;
   anything block 3 declares that dark needs to change, block 4 must restate.
   Rather than track which tokens that is, every block declares the complete
   semantic set. Identical values get repeated. That is the price of the
   omission never being silent, and `contrast.test.mjs` measures every
   palette × theme combination so a token that leaks across a block is a
   failing build.

   Ramps are palette-level, not theme-level, so `--signal-*` and `--anchor-*`
   are declared in each palette's light block only. Status and chart are theme-level but
   not palette-level, so they are declared in blocks 1 and 2 only.

   ---------------------------------------------------------------------------
   Adding a palette
   ---------------------------------------------------------------------------
   Two blocks — `[data-palette='<name>']` and
   `[data-palette='<name>'][data-theme='dark'], [data-palette='<name>'].dark` —
   appended after the ones below, each declaring the same complete set, plus
   two more measured columns in the ratio tables. Nothing else in the system
   changes: tokens.css never names a ramp step, and no component may assume
   which palette is mounted or which way its hover moves.

   Every ratio in a comment here was measured against `--bg` in the palette and
   theme whose block it sits in, unless the comment names another background.
   An unmeasured colour is not cleared for use.
   ========================================================================== */

/* ==========================================================================
   BLOCK 1 — ember (default), light
   --------------------------------------------------------------------------
   Miltinson Amber over Miltinson Forest, near-achromatic greys. This is the
   brand the system shipped with; the ramps are unchanged from the tokens.css
   that predates the split.

   This block also carries the palette-independent status and chart families,
   because `:root` is the only selector that is true under every palette.
   ========================================================================== */
:root {
  /* --- The neutral dial ---------------------------------------------------
     tokens.css mixes every `--ink-*` step as
     `oklch(<L>% calc(<C> * var(--n-mult)) var(--n-h))`, so a palette can tint
     the greys without touching a single lightness. Lightness is what carries
     contrast, so every measured neutral ratio is identical under both dials —
     asserted, not assumed, in contrast.test.mjs.

     These are declared here and not in tokens.css on purpose. `:root` and
     `[data-palette='slate']` are both (0,1,0), and tokens.css @imports this
     file at the top, so a `--n-h` declared in tokens.css's own `:root` would
     come later at equal specificity and beat every palette block — the dial
     would exist and never turn. The palette owns the dial; tokens.css reads
     it. ------------------------------------------------------------------ */
  --n-h: 247; /* neutral hue */
  --n-mult: 1; /* neutral chroma multiplier — 1 near-achromatic, 1.6 charcoal */

  /* --- Ramps -------------------------------------------------------------- */

  /* Signal — Miltinson Amber. Warm, craftsman, honest. The primary accent.
     Used for CTAs, links on dark, eyebrow underlines, key stats. */
  --signal-50: oklch(97.5% 0.025 75);
  --signal-100: oklch(94% 0.055 75);
  --signal-200: oklch(89% 0.1 75);
  --signal-300: oklch(83% 0.14 72);
  --signal-400: oklch(78% 0.16 68);
  --signal-500: oklch(72.5% 0.175 65); /* primary — Miltinson Amber */
  --signal-600: oklch(63% 0.165 55);
  --signal-700: oklch(52% 0.145 45);
  --signal-800: oklch(40% 0.115 40);
  --signal-900: oklch(28% 0.08 38);

  /* Anchor — Forest. Secondary brand anchor used sparingly:
     coaching/guides surfaces, trust marks. It is no longer what success is
     made of — see the status section. */
  --anchor-50: oklch(96% 0.018 165);
  --anchor-100: oklch(91% 0.035 165);
  --anchor-200: oklch(82% 0.055 162);
  --anchor-300: oklch(70% 0.075 160);
  --anchor-400: oklch(55% 0.085 158);
  --anchor-500: oklch(42% 0.08 156); /* primary — Miltinson Forest */
  --anchor-600: oklch(34% 0.065 156);
  --anchor-700: oklch(26% 0.05 156);
  --anchor-800: oklch(19% 0.035 156);
  --anchor-900: oklch(13% 0.025 156);

  /* --- Brand semantics ----------------------------------------------------
     Amber peaks bright, so `--accent` is the 500 step carrying ink text and
     hover LIGHTENS to 400. Slate does the opposite. A component reads
     `--accent-hover`/`--accent-press` and never assumes a direction. ------ */
  --accent: var(--signal-500); /* fill — 2.53:1, fill-only */
  --accent-hover: var(--signal-400); /* fill — 2.06:1, fill-only */
  --accent-press: var(--signal-600); /* fill — 3.69:1 */
  --accent-fg: var(--ink-1000); /* on the three fills: 8.30 / 10.17 / 5.69 */
  /* The tinted brand surface — a soft chip or an inset panel, the brand
     counterpart to --status-*-tint. It carries --accent-ink and nothing else,
     and like every tint it has a dark override: a fill that stays put while
     its text follows the theme is the pair that inverts. */
  --accent-tint: var(--signal-50); /* with --accent-ink: 8.96:1 */
  /* Brand amber at legible weight. --accent is a fill: at 2.53:1 against --bg
     it cannot be a glyph, a mark, or a label. Anything amber that a user has
     to *read* — a filled rating star, a slider thumb — uses this. */
  --accent-ink: var(--signal-800); /* 9.69:1 */
  /* The same idea as --accent-ink, one surface over: amber that can be read on
     an inverted band. --accent-ink is tuned for --bg, so on the band it is the
     wrong end of the ramp — the docs eyebrow used --signal-400 and fell to
     2.07:1 once the band flipped white. */
  --accent-ink-inverse: var(--signal-400); /* on --bg-inverse — 10.17:1 */

  --anchor: var(--anchor-500); /* fill — 8.13:1 */
  --anchor-hover: var(--anchor-400); /* fill — 4.65:1 */
  --anchor-press: var(--anchor-600); /* fill — 11.41:1 */
  --anchor-fg: var(--ink-0); /* on the three fills: 8.13 / 4.65 / 11.41 */
  --anchor-tint: var(--anchor-50); /* with --anchor-ink: 10.23:1 */
  /* Forest at legible weight, the --accent-ink of the second brand colour.
     --anchor is 8.13:1 and could carry text on white, but it is 2.58:1 on a
     black page, so text drawn in it needs a token that inverts. */
  --anchor-ink: var(--anchor-600); /* 11.41:1 */

  --focus-ring: var(--ink-1000); /* 21:1 — high-contrast focus, ink-on-bg */

  --link: var(--ink-1000); /* 21:1 */
  --link-hover: var(--signal-800); /* 9.69:1 — was --signal-700 at 5.86:1 */
  --link-visited: var(--ink-700); /* 13.62:1 */
  /* A link sitting on a filled surface keeps the fill's own foreground. See
     the `.ds-button a` block in tokens.css for why this is not just a nicety. */
  --link-on-fill: currentColor;

  /* ======================================================================
     STATUS — palette-independent, five members per state
     ----------------------------------------------------------------------
     Status used to be made of brand: `--status-success` was `--anchor-500`
     and `--status-warning` was `--signal-600`, which made a caution badge the
     exact colour of the primary CTA. An amber button beside an amber caution
     badge is not a signal, and under a second palette it would have been a
     teal one. Success is green because success is green, in every brand this
     system will ever carry, so these four hues are owned here and no
     `[data-palette]` block below touches them.

     | member                 | job                                  | floor |
     | ---------------------- | ------------------------------------ | ----- |
     | --status-X             | the fill                             | 3:1   |
     | --status-X-on          | text drawn ON that fill              | 4.5:1 |
     | --status-X-fg          | status text on --bg / --surface      | 4.5:1 |
     | --status-X-tint        | quiet alert or row background        | —     |
     | --status-X-tint-edge   | that tint's hairline                 | —     |

     `--status-X` is NEVER text. The fills carry over to dark unchanged — each
     one clears 3:1 against both a white and a black page — and every `-fg`
     and `-tint` is re-picked in block 2.
     ====================================================================== */
  --status-success: oklch(51.9% 0.145 150); /* fill — 5.17:1 light, 4.06:1 dark */
  --status-success-on: var(--ink-0); /* on the fill — 5.17:1 */
  --status-success-fg: oklch(44% 0.12 150); /* 7.35:1 on --bg */
  --status-success-tint: oklch(96.5% 0.03 150); /* with -fg: 6.71:1 */
  --status-success-tint-edge: oklch(88% 0.06 150); /* decorative hairline on the tint */

  /* Warning is the documented exception. Yellow cannot reach 3:1 on white and
     still read as yellow — push it dark enough to clear the floor and it is
     olive. So `--status-warning` is 1.87:1, below the SC 1.4.11 floor, and a
     warning fill or rule must be EDGED with `--status-warning-border`, which
     is what actually carries the 3:1. Painting a bare warning fill is a bug;
     `organisms/Toast.css` is the worked example. */
  --status-warning: oklch(80.25% 0.16 85); /* fill — 1.87:1 light, 11.22:1 dark — must be edged */
  --status-warning-border: oklch(61.9% 0.14 70); /* 3.76:1 — the edge that carries SC 1.4.11 */
  --status-warning-on: var(--ink-1000); /* on the fill — 11.22:1 */
  --status-warning-fg: oklch(47% 0.105 72); /* 6.97:1 on --bg */
  --status-warning-tint: oklch(96.5% 0.05 85); /* with -fg: 6.24:1 */
  --status-warning-tint-edge: oklch(87% 0.1 85); /* decorative hairline on the tint */

  --status-danger: oklch(54.85% 0.2 27); /* fill — 5.41:1 light, 3.88:1 dark */
  --status-danger-on: var(--ink-0); /* on the fill — 5.41:1 */
  --status-danger-fg: oklch(47% 0.19 27); /* 7.54:1 on --bg */
  --status-danger-tint: oklch(96.5% 0.025 27); /* with -fg: 6.69:1 */
  --status-danger-tint-edge: oklch(87% 0.07 27); /* decorative hairline on the tint */

  --status-info: oklch(54% 0.155 250); /* fill — 5.06:1 light, 4.15:1 dark */
  --status-info-on: var(--ink-0); /* on the fill — 5.06:1 */
  --status-info-fg: oklch(46% 0.14 250); /* 7.09:1 on --bg */
  --status-info-tint: oklch(96.5% 0.03 250); /* with -fg: 6.38:1 */
  --status-info-tint-edge: oklch(87% 0.07 250); /* decorative hairline on the tint */

  /* ======================================================================
     DATA / CATEGORICAL — also palette-independent
     ----------------------------------------------------------------------
     Eight hues at ONE lightness and ONE chroma, so no series shouts louder
     than another: a categorical scale whose members differ in lightness reads
     as an ordering the data does not have. They are fills and strokes, never
     labels — 3.34:1 at the low end is a non-text graphic under SC 1.4.11, and
     an in-chart label uses `--fg` or, in dark, the chart colour itself, which
     clears 4.5:1 there.

     The order is not the hue order. Adjacent series are the ones a reader has
     to tell apart, so consecutive slots are at least 90° apart on the hue
     circle; sorting them by hue would put two neighbouring greens side by side
     in every two-series chart.

     A legend still needs a label or a shape. Colour is never the only channel
     that carries meaning (SC 1.4.1), and eight categories is exactly where
     that rule starts to matter.
     ====================================================================== */
  --chart-1: oklch(62% 0.13 25); /* 3.87:1 */
  --chart-2: oklch(62% 0.13 205); /* 3.34:1 */
  --chart-3: oklch(62% 0.13 115); /* 3.55:1 */
  --chart-4: oklch(62% 0.13 295); /* 3.81:1 */
  --chart-5: oklch(62% 0.13 70); /* 3.74:1 */
  --chart-6: oklch(62% 0.13 250); /* 3.63:1 */
  --chart-7: oklch(62% 0.13 160); /* 3.42:1 */
  --chart-8: oklch(62% 0.13 340); /* 3.90:1 */
  /* The plot furniture is neutral, so it moves with the palette's grey dial
     and never competes with a series for attention. */
  --chart-grid: var(--border-strong); /* decorative — 1.53:1 */
  --chart-axis: var(--border-control); /* 3.64:1 — the axis is a meaningful line */
}

/* ==========================================================================
   BLOCK 2 — ember, dark
   --------------------------------------------------------------------------
   `.dark` is accepted as a compatibility alias because most theme switchers
   default to a class strategy — next-themes in particular. Either convention
   works; `[data-theme='dark']` remains the one the system documents.

   This block also carries the palette-independent dark values for status and
   chart, for the same reason block 1 carries their light values.
   ========================================================================== */
[data-theme='dark'],
.dark {
  /* Amber is bright enough to stay put: the same three fills clear 3:1 on a
     black page as comfortably as they do on a white one, and ink text on them
     is unchanged. Only the ink and tint tokens, which are tuned to a page,
     have to move. */
  --accent: var(--signal-500); /* fill — 8.30:1 */
  --accent-hover: var(--signal-400); /* fill — 10.17:1 */
  --accent-press: var(--signal-600); /* fill — 5.69:1 */
  --accent-fg: var(--ink-1000); /* on the three fills: 8.30 / 10.17 / 5.69 */
  /* The brand tint, mirrored the way the status tints are. Left at --signal-50
     it stayed a 97%-light chip on a black page while the ink on it inverted —
     the same defect the status tints were given dark values to fix. It is the
     900 step now, and it is never the un-inverted 50. */
  --accent-tint: var(--signal-900); /* with --accent-ink: 7.26:1 */
  /* --signal-800 is 9.69:1 on white and 2.17:1 on black, so the readable amber
     has to invert even though the fill does not. */
  --accent-ink: var(--signal-400); /* 10.17:1 */
  --accent-ink-inverse: var(--signal-800); /* on --bg-inverse — 9.69:1 */

  /* Forest at 42% L is 2.58:1 on a black page — under the 3:1 SC 1.4.11 asks
     of a fill — so --anchor lifts three steps and its foreground flips to ink.
     This is the same class of bug as the un-inverted tints: the token was
     correct on the page it was picked for and on no other. */
  --anchor: var(--anchor-300); /* fill — 8.13:1 */
  --anchor-hover: var(--anchor-200); /* fill — 12.30:1 */
  --anchor-press: var(--anchor-400); /* fill — 4.51:1 */
  --anchor-fg: var(--ink-1000); /* on the three fills: 8.13 / 12.30 / 4.51 */
  --anchor-tint: var(--anchor-800); /* with --anchor-ink: 10.73:1 */
  --anchor-ink: var(--anchor-200); /* 12.30:1 */

  /* The light theme's focus ring is ink-on-white. Left un-inverted it stays
     pure black against a pure black page, which makes every
     `outline: 2px solid var(--focus-ring)` in the component library invisible
     in dark mode — a silent failure of the focusVisibleRequired contract. */
  --focus-ring: var(--ink-0); /* 21:1 */

  --link: var(--ink-0); /* 21:1 */
  --link-hover: var(--signal-400); /* 10.17:1 */
  --link-visited: var(--ink-200); /* 16.99:1 */
  --link-on-fill: currentColor;

  /* --- Status, dark ------------------------------------------------------
     The four fills and their `-on` partners are unchanged from block 1 — each
     clears 3:1 against a black page as well as a white one, which is what
     lets a status colour mean the same thing in both themes. Warning still
     cannot be drawn bare, but on ink it needs no lift: the fill itself is
     11.22:1 here, so the border token is the fill. -------------------- */
  --status-warning-border: var(--status-warning); /* 11.22:1 */

  --status-success-fg: oklch(78% 0.13 150); /* 11.03:1 on --bg */
  --status-warning-fg: oklch(82% 0.13 82); /* 11.91:1 on --bg */
  --status-danger-fg: oklch(72% 0.16 27); /* 7.91:1 on --bg */
  --status-info-fg: oklch(74% 0.12 250); /* 9.16:1 on --bg */

  --status-success-tint: oklch(20% 0.04 150); /* with -fg: 9.42:1 */
  --status-warning-tint: oklch(22% 0.05 82); /* with -fg: 9.85:1 */
  --status-danger-tint: oklch(22% 0.06 27); /* with -fg: 6.63:1 */
  --status-info-tint: oklch(20% 0.05 250); /* with -fg: 7.89:1 */

  --status-success-tint-edge: oklch(32% 0.06 150); /* decorative hairline on the tint */
  --status-warning-tint-edge: oklch(34% 0.07 82); /* decorative hairline on the tint */
  --status-danger-tint-edge: oklch(34% 0.09 27); /* decorative hairline on the tint */
  --status-info-tint-edge: oklch(32% 0.07 250); /* decorative hairline on the tint */

  /* --- Data, dark --------------------------------------------------------
     Same eight hues, lifted and slightly desaturated for an ink page. At
     8.61–9.59:1 these also clear 4.5:1, so a dark chart may label a series in
     its own colour where a light one may not. ------------------------- */
  --chart-1: oklch(74% 0.12 25); /* 8.66:1 */
  --chart-2: oklch(74% 0.12 205); /* 9.52:1 */
  --chart-3: oklch(74% 0.12 115); /* 9.31:1 */
  --chart-4: oklch(74% 0.12 295); /* 8.78:1 */
  --chart-5: oklch(74% 0.12 70); /* 8.92:1 */
  --chart-6: oklch(74% 0.12 250); /* 9.16:1 */
  --chart-7: oklch(74% 0.12 160); /* 9.59:1 */
  --chart-8: oklch(74% 0.12 340); /* 8.61:1 */
  --chart-grid: var(--border-strong); /* decorative — 1.54:1 */
  --chart-axis: var(--border-control); /* 3.95:1 */
}

/* ==========================================================================
   BLOCK 3 — slate, light
   --------------------------------------------------------------------------
   Teal over slate blue, on charcoal greys. The second palette exists to prove
   the dial turns, so it differs from ember structurally and not only in hue:

   - `--n-mult: 1.6` tints the neutrals hard enough that charcoal reads as
     charcoal. Not one measured neutral ratio moves — the multiplier scales
     chroma and lightness is what carries contrast.
   - Teal peaks at 3.41:1 (`--signal-500`), which is a fill and nothing more.
     So `--accent` is the 600 step with WHITE text, and hover DARKENS to 700.
     Ember's amber is the exact opposite: the 500 step with ink text, hover
     lightening to 400. Any component that hardcodes a direction is broken by
     one of the two palettes; read `--accent-hover` / `--accent-press`.

   Status and chart are absent from this block on purpose. That absence is
   what makes them palette-independent.
   ========================================================================== */
[data-palette='slate'] {
  --n-h: 252; /* neutral hue */
  --n-mult: 1.6; /* charcoal, not near-achromatic */

  /* Signal — Teal. */
  --signal-50: oklch(97.5% 0.02 195);
  --signal-100: oklch(94% 0.045 195);
  --signal-200: oklch(87% 0.08 193);
  --signal-300: oklch(79% 0.1 191);
  --signal-400: oklch(71% 0.112 189);
  --signal-500: oklch(62% 0.115 187); /* peak teal — 3.41:1, a fill and nothing more */
  --signal-600: oklch(52% 0.105 186); /* the accent step — 5.11:1 */
  --signal-700: oklch(43% 0.09 185);
  --signal-800: oklch(33% 0.07 184);
  --signal-900: oklch(23% 0.05 183);

  /* Anchor — slate blue. --anchor-400 is deliberately parked in the narrow
     band that clears 4.5:1 against BOTH white and black (4.58 / 4.59). Any
     single colour can only do that between 4.50:1 and 4.67:1, because the two
     ratios multiply to 21; that is what lets one ramp step be the hover fill
     in light and the press fill in dark. */
  --anchor-50: oklch(96.5% 0.015 262);
  --anchor-100: oklch(93% 0.03 262);
  --anchor-200: oklch(86% 0.05 262);
  --anchor-300: oklch(76% 0.07 262);
  --anchor-400: oklch(56.5% 0.085 262);
  --anchor-500: oklch(50% 0.09 262); /* primary — 6.04:1 */
  --anchor-600: oklch(41% 0.08 262);
  --anchor-700: oklch(32% 0.065 262);
  --anchor-800: oklch(23% 0.05 262);
  --anchor-900: oklch(16% 0.035 262);

  --accent: var(--signal-600); /* fill — 5.11:1 */
  --accent-hover: var(--signal-700); /* fill — 7.57:1 — DARKER, unlike ember */
  --accent-press: var(--signal-800); /* fill — 11.64:1 */
  --accent-fg: var(--ink-0); /* on the three fills: 5.11 / 7.57 / 11.64 */
  --accent-tint: var(--signal-50); /* with --accent-ink: 7.09:1 */
  --accent-ink: var(--signal-700); /* 7.57:1 */
  --accent-ink-inverse: var(--signal-300); /* on --bg-inverse — 11.32:1 */

  --anchor: var(--anchor-500); /* fill — 6.04:1 */
  --anchor-hover: var(--anchor-400); /* fill — 4.58:1 */
  --anchor-press: var(--anchor-600); /* fill — 8.89:1 */
  --anchor-fg: var(--ink-0); /* on the three fills: 6.04 / 4.58 / 8.89 */
  --anchor-tint: var(--anchor-50); /* with --anchor-ink: 8.03:1 */
  --anchor-ink: var(--anchor-600); /* 8.89:1 */

  --focus-ring: var(--ink-1000); /* 21:1 */

  --link: var(--ink-1000); /* 21:1 */
  --link-hover: var(--signal-800); /* 11.64:1 */
  --link-visited: var(--ink-700); /* 13.62:1 */
  --link-on-fill: currentColor;
}

/* ==========================================================================
   BLOCK 4 — slate, dark
   --------------------------------------------------------------------------
   (0,2,0), the only compound selector here, which is what makes it win over
   block 3 without an `!important`. It restates the whole semantic set rather
   than diffing block 2, because block 3 is later in source order than block 2
   and would otherwise hand slate's LIGHT values to a dark page — the exact
   shape of the un-inverted-tint bug, one level up.

   Teal keeps its direction here: the fills step DOWN the ramp on hover and
   press the way they do in slate light. What moves is where the triad starts
   — 300/400/500 instead of 600/700/800 — and with it `--accent-fg`, which is
   ink on a bright teal rather than white on a deep one. Only a ramp step
   between 4.50:1 and 4.67:1 can carry both, and a CTA is not the place to
   spend that margin.
   ========================================================================== */
[data-palette='slate'][data-theme='dark'],
[data-palette='slate'].dark {
  --accent: var(--signal-300); /* fill — 11.32:1 */
  --accent-hover: var(--signal-400); /* fill — 8.58:1 — still DARKER, as in slate light */
  --accent-press: var(--signal-500); /* fill — 6.16:1 */
  --accent-fg: var(--ink-1000); /* on the three fills: 11.32 / 8.58 / 6.16 */
  --accent-tint: var(--signal-900); /* with --accent-ink: 11.47:1 */
  --accent-ink: var(--signal-200); /* 14.61:1 */
  --accent-ink-inverse: var(--signal-800); /* on --bg-inverse — 11.64:1 */

  --anchor: var(--anchor-300); /* fill — 9.76:1 */
  --anchor-hover: var(--anchor-200); /* fill — 13.72:1 */
  --anchor-press: var(--anchor-400); /* fill — 4.59:1 */
  --anchor-fg: var(--ink-1000); /* on the three fills: 9.76 / 13.72 / 4.59 */
  --anchor-tint: var(--anchor-800); /* with --anchor-ink: 11.06:1 */
  --anchor-ink: var(--anchor-200); /* 13.72:1 */

  --focus-ring: var(--ink-0); /* 21:1 */

  --link: var(--ink-0); /* 21:1 */
  --link-hover: var(--signal-300); /* 11.32:1 */
  --link-visited: var(--ink-200); /* 16.99:1 */
  --link-on-fill: currentColor;
}

/* ==========================================================================
   BLOCK 5 — miltinson, light
   --------------------------------------------------------------------------
   Teal over indigo, on violet-leaning greys. The palette this system's own
   sites render in, contributed so they stop hand-overriding brand tokens in a
   block no contrast gate can see. Named rather than described by domain: the
   palette is the system's because it is inert until selected and contrast-
   gated, and naming one consumer's site in a shipped stylesheet is the part
   that did not need to be here.

   The three teals the properties already ship are pinned to ramp steps rather
   than re-picked, so adoption is a swap and not a redesign:

     --signal-300  #5eead4    was `teal-light`
     --signal-500  #14b8a6    was `teal`      — the resting CTA fill
     --signal-600  #0d9488    was `teal-dark`

   Structurally this follows ember, not slate: `--accent` is the 500 step with
   ink text, hover LIGHTENS to 400 and press darkens to 600. Teal peaks bright
   enough that the whole triad carries `--ink-1000` at AA in both themes
   (8.44 / 11.10 / 5.60), which is what lets one `--accent-fg` serve both
   blocks — the thing slate has to spend two different foregrounds on.

   Status and chart are absent from this block on purpose. That absence is
   what makes them palette-independent.
   ========================================================================== */
[data-palette='miltinson'] {
  --n-h: 252; /* neutral hue */
  --n-mult: 1; /* near-achromatic, as ember */

  /* Signal — Miltinson Teal. */
  --signal-50: oklch(97.5% 0.02 180);
  --signal-100: oklch(94% 0.045 180);
  --signal-200: oklch(90% 0.08 181);
  --signal-300: oklch(85.5% 0.125 181); /* #5eead4 */
  --signal-400: oklch(78% 0.13 182);
  --signal-500: oklch(70.4% 0.123 182.5); /* peak teal, #14b8a6 — 2.49:1, a fill and nothing more */
  --signal-600: oklch(60.02% 0.1038 184.7); /* #0d9488 */
  --signal-700: oklch(50% 0.088 186);
  --signal-800: oklch(38% 0.068 187); /* the readable step — 9.65:1 */
  --signal-900: oklch(27% 0.048 188);

  /* Anchor — indigo. Held ~90 degrees off the signal so a trust mark never
     reads as a muted CTA, and pulled toward the neutral hue so it sits on the
     greys rather than on top of them. --anchor-400 is parked in the narrow
     band that clears 4.5:1 against both white and black (4.61 / 4.55), which
     is what lets one step be the hover fill in light and the press fill in
     dark. */
  --anchor-50: oklch(96.5% 0.015 275);
  --anchor-100: oklch(93% 0.03 275);
  --anchor-200: oklch(86% 0.05 274);
  --anchor-300: oklch(76% 0.07 272);
  --anchor-400: oklch(56.5% 0.09 270);
  --anchor-500: oklch(50% 0.095 270); /* primary — 6.09:1 */
  --anchor-600: oklch(41% 0.085 270);
  --anchor-700: oklch(32% 0.07 270);
  --anchor-800: oklch(23% 0.055 270);
  --anchor-900: oklch(16% 0.04 270);

  --accent: var(--signal-500); /* fill — 2.49:1, fill-only */
  --accent-hover: var(--signal-400); /* fill — 1.89:1 — LIGHTER, as ember */
  --accent-press: var(--signal-600); /* fill — 3.74:1 */
  --accent-fg: var(--ink-1000); /* on the three fills: 8.44 / 11.10 / 5.61 */
  --accent-tint: var(--signal-50); /* with --accent-ink: 9.04:1 */
  --accent-ink: var(--signal-800); /* 9.65:1 */
  --accent-ink-inverse: var(--signal-400); /* on --bg-inverse — 11.10:1 */

  --anchor: var(--anchor-500); /* fill — 6.09:1 */
  --anchor-hover: var(--anchor-400); /* fill — 4.61:1 */
  --anchor-press: var(--anchor-600); /* fill — 8.96:1 */
  --anchor-fg: var(--ink-0); /* on the three fills: 6.09 / 4.61 / 8.96 */
  --anchor-tint: var(--anchor-50); /* with --anchor-ink: 8.08:1 */
  --anchor-ink: var(--anchor-600); /* 8.96:1 */

  --focus-ring: var(--ink-1000); /* 21:1 */

  --link: var(--ink-1000); /* 21:1 */
  --link-hover: var(--signal-800); /* 9.65:1 */
  --link-visited: var(--ink-700); /* 13.63:1 */
  --link-on-fill: currentColor;
}

/* ==========================================================================
   BLOCK 6 — miltinson, dark
   --------------------------------------------------------------------------
   (0,2,0), so it wins over block 5 without an `!important`. It restates the
   whole semantic set rather than diffing block 2, for the same reason slate's
   dark block does: block 5 is later in source order than block 2 and would
   otherwise hand miltinson's LIGHT values to a dark page.

   The accent triad does not move between themes here. 500/400/600 carries
   `--ink-1000` at 8.44 / 11.10 / 5.61 on white and at exactly the same three
   ratios on black, because all three are measured against the fill rather
   than against the page. A teal bright enough to be a fill on white is bright
   enough to be one on black; that is the property ember's amber has and
   slate's deeper teal does not.
   ========================================================================== */
[data-palette='miltinson'][data-theme='dark'],
[data-palette='miltinson'].dark {
  --accent: var(--signal-500); /* fill — 8.44:1 */
  --accent-hover: var(--signal-400); /* fill — 11.10:1 — LIGHTER, as in miltinson light */
  --accent-press: var(--signal-600); /* fill — 5.61:1 */
  --accent-fg: var(--ink-1000); /* on the three fills: 8.44 / 11.10 / 5.61 */
  --accent-tint: var(--signal-900); /* with --accent-ink: 7.81:1 */
  --accent-ink: var(--signal-400); /* 11.10:1 */
  --accent-ink-inverse: var(--signal-800); /* on --bg-inverse — 9.65:1 */

  --anchor: var(--anchor-300); /* fill — 9.71:1 */
  --anchor-hover: var(--anchor-200); /* fill — 13.66:1 */
  --anchor-press: var(--anchor-400); /* fill — 4.55:1 */
  --anchor-fg: var(--ink-1000); /* on the three fills: 9.71 / 13.66 / 4.55 */
  --anchor-tint: var(--anchor-800); /* with --anchor-ink: 11.05:1 */
  --anchor-ink: var(--anchor-200); /* 13.66:1 */

  --focus-ring: var(--ink-0); /* 21:1 */

  --link: var(--ink-0); /* 21:1 */
  --link-hover: var(--signal-300); /* 14.20:1 */
  --link-visited: var(--ink-200); /* 16.99:1 */
  --link-on-fill: currentColor;
}

/* ==========================================================================
   Miltinson Design System — Design Tokens
   mobile.css — the platform layer
   ==========================================================================
   The platform dial: geometry and the small end of the type ramp, retuned for
   a device held at arm's length rather than sat on a desk. @imported by
   tokens.css alongside palettes.css.

   This file changes no colour, and that is load-bearing rather than a
   coincidence of scope. Every contrast ratio the system claims is measured
   against the values in tokens.css and palettes.css and asserted in
   contrast.test.mjs. A platform layer that moved a single colour token would
   invalidate all of them silently — the assertions would still pass, because
   they read the tokens this file does not touch, while a phone rendered
   something nobody measured. Radii, font sizes, gutters and container widths
   have no contrast consequences; colour does. Keep colour out of here.
   ========================================================================== */

/* --- Attribute selector --------------------------------------------------
   `:root[data-platform='mobile']`, not `[data-platform='mobile']`, and the
   extra `:root` is the whole point. Bare `[data-platform='mobile']` is (0,1,0)
   — exactly the specificity of tokens.css's own `:root`. tokens.css @imports
   this file at the top, so every declaration here lands *before* the `:root`
   block it means to override, and at equal specificity the later rule wins:
   the attribute block would parse cleanly, match correctly, and do nothing at
   all. Qualifying with `:root` makes it (0,2,0), which outranks that `:root`
   regardless of source order.

   Only the tokens listed below are redeclared. --radius-none, --radius-pill
   and --container-lg upward are deliberately absent: they inherit from
   tokens.css, and a value repeated here is a value that has to be kept in step
   with two files.
   ------------------------------------------------------------------------- */
:root[data-platform='mobile'] {
  /* Radii — coarser across the board. The desktop ramp starts at 2px, which
     is a legible corner on a monitor at desk distance and reads as a rendering
     artefact on a phone at arm's length: too small to look intentional, big
     enough to look like the edge failed to paint. Each step roughly doubles
     what the desktop set uses so the corner survives the viewing distance. */
  --radius-xs: 4px;
  --radius-sm: 8px;
  --radius-md: 12px;
  --radius-lg: 16px;
  --radius-xl: 24px;

  /* Type — the small end only, floored. 10px and 11px are fine on a monitor
     and are not legible on a phone, so --fs-3xs and --fs-2xs collapse onto a
     shared 12px floor and the two steps above them lift with them.

     Nothing at or above --fs-md moves, on purpose. Scaling the whole ramp is
     the obvious instinct and the wrong one: body copy and every heading are
     what line lengths, component heights and wrap points are built around, so
     shifting them reflows every layout in the system on one device class. The
     small end is captions, eyebrows and meta text — it carries no layout. */
  --fs-3xs: 12px;
  --fs-2xs: 12px;
  --fs-xs: 13px;
  --fs-sm: 15px;

  /* A flat gutter. The desktop value is max(20px, round(4vw, 4px)) — the
     viewport-proportional term is what keeps a wide page from running edge to
     edge, and on a phone it is pure loss: it takes its cut from a content
     column that has no width to spare. A fixed 16px each side is what a 390px
     viewport can afford. */
  --gutter: 16px;

  /* Fluid shells at the small end. --container-sm and --container-md are fixed
     640px and 768px, both wider than a 390px viewport, so a shell built on
     either overflows and the page scrolls sideways. At 100% they are the
     gutter's job to inset. --container-lg and up are untouched: nothing that
     asks for a 1024px shell is being laid out for a phone. */
  --container-sm: 100%;
  --container-md: 100%;
}

/* --- Breakpoint fallback -------------------------------------------------
   The same layer, reached without JavaScript. Both mechanisms exist because
   they answer different questions. An app that renders a known-mobile shell
   already knows what it is and states it once, on the root element. A
   responsive site cannot know — it is one document served to everything — so
   it needs the viewport to say it instead, and `pointer: coarse` is the half
   of this query that keeps a narrow desktop window out: a browser dragged to
   400px wide is still a mouse, and shipping thumb-sized radii and a floored
   type ramp to it would be a regression, not an adaptation.

   `:root:root` is a specificity bump, not a typo. A bare `:root` inside a
   media query is still (0,1,0) — media queries do not add specificity — and
   would lose to tokens.css's `:root` for the same source-order reason as
   above. Repeating the selector makes it (0,2,0).

   The values below are written out rather than shared with the block above
   through a var() indirection. The repetition is deliberate: an indirection
   would introduce a third dial that neither `data-platform` nor this query
   owns, and whichever of the two set it last would silently decide the other. */
@media (max-width: 480px) and (pointer: coarse) {
  :root:root {
    --radius-xs: 4px;
    --radius-sm: 8px;
    --radius-md: 12px;
    --radius-lg: 16px;
    --radius-xl: 24px;

    --fs-3xs: 12px;
    --fs-2xs: 12px;
    --fs-xs: 13px;
    --fs-sm: 15px;

    --gutter: 16px;

    --container-sm: 100%;
    --container-md: 100%;
  }
}


:root {
  /* ======================================================================
     COLOR — the neutral ramp
     ----------------------------------------------------------------------
     Every step is `oklch(<L>% calc(<C> * var(--n-mult)) var(--n-h))`. The two
     dials are declared by the palette (palettes.css), never here — `:root` and
     `[data-palette='…']` are both (0,1,0), and this file's `:root` comes after
     the @import, so a default written here would outrank every palette and the
     dial would never turn.

     What the split buys: lightness is untouched by either dial, and lightness
     is what carries contrast. So a palette can take the greys from
     near-achromatic (`--n-mult: 1`) to charcoal (`1.6`) and not one measured
     neutral ratio moves. contrast.test.mjs measures the ramp under every
     palette rather than taking that on trust.

     `--ink-0` and `--ink-1000` are pinned white and black. They are the page
     and its inverse, not steps in a ramp, and a tinted white is a different
     claim entirely.
     ====================================================================== */
  --ink-0: #ffffff; /* pure white */
  --ink-50: oklch(98.4% calc(0.002 * var(--n-mult)) var(--n-h)); /* off-white surface */
  --ink-100: oklch(96.2% calc(0.003 * var(--n-mult)) var(--n-h)); /* hairline backgrounds */
  --ink-200: oklch(92.8% calc(0.004 * var(--n-mult)) var(--n-h)); /* dividers */
  --ink-300: oklch(86% calc(0.005 * var(--n-mult)) var(--n-h)); /* borders */
  --ink-400: oklch(70% calc(0.006 * var(--n-mult)) var(--n-h)); /* disabled / placeholder */
  --ink-500: oklch(55% calc(0.008 * var(--n-mult)) var(--n-h)); /* tertiary text */
  --ink-600: oklch(42% calc(0.01 * var(--n-mult)) var(--n-h)); /* secondary text */
  --ink-700: oklch(30% calc(0.01 * var(--n-mult)) var(--n-h)); /* high-contrast secondary */
  --ink-800: oklch(20% calc(0.01 * var(--n-mult)) var(--n-h)); /* near-black surfaces */
  --ink-900: oklch(12% calc(0.008 * var(--n-mult)) var(--n-h)); /* deep surface */
  --ink-950: oklch(6% calc(0.006 * var(--n-mult)) var(--n-h)); /* card on dark */
  --ink-1000: #000000; /* pure black */

  /* ======================================================================
     COLOR — semantic (what components reference)
     ====================================================================== */
  --bg: var(--ink-0); /* page background */
  --bg-subtle: var(--ink-50); /* zebra / panel */
  --bg-muted: var(--ink-100); /* code blocks, inset wells */
  --bg-inverse: var(--ink-1000); /* dark hero, footer */

  --surface: var(--ink-0); /* cards */
  --surface-2: var(--ink-50); /* nested cards */
  --surface-3: var(--ink-100); /* deeper inset */

  /* Decorative edges. 1.24:1 and 1.53:1 against --bg — below the 3:1 that
     SC 1.4.11 asks of a control boundary, on purpose. These separate surfaces
     from each other (card seams, table rules, dividers, floating-panel edges),
     where nothing depends on seeing the line. The moment an edge is the only
     thing telling a user where an input or a toggle is, it is a control
     boundary and wants --border-control instead. */
  --border: var(--ink-200); /* decorative hairline — 1.24:1 */
  --border-strong: var(--ink-300); /* decorative, emphasized — 1.53:1 */
  --border-inverse: oklch(100% 0 0 / 0.12); /* on dark */
  /* On the same dial as the ramp: it is a neutral, so it follows the palette's
     greys rather than sitting at a fixed hue beside them. */
  --border-control: oklch(
    62% calc(0.008 * var(--n-mult)) var(--n-h)
  ); /* 3.64:1 on --bg — control edges, SC 1.4.11 */

  --fg: var(--ink-1000); /* primary text */
  --fg-1: var(--ink-1000); /* alias */
  --fg-2: var(--ink-600); /* secondary text */
  --fg-3: var(--ink-500); /* tertiary / meta */
  --fg-4: var(--ink-400); /* decorative / non-informational grey — 2.67:1 */
  --fg-disabled: var(--ink-500); /* 4.85:1 — disabled control text */
  --fg-inverse: var(--ink-0); /* on dark */
  /* Muted text on an inverted band. --fg-inverse was the only foreground the
     inverse pair shipped, so anything secondary on a dark hero or footer had
     to reach for a fixed ramp step — and a fixed step does not flip when the
     band does. The docs site hit exactly that: --ink-300 on a band that turns
     white in dark mode measured 1.53:1, on all 71 pages via the footer.

     These are the mirror theme's own --fg-2 / --fg-3. --bg-inverse in one
     theme is exactly --bg in the other, so their ratio on the band is the
     ratio the mirror theme already measures against its page — asserted as a
     pair in contrast.test.mjs rather than against --bg, which would be the
     wrong background for them. */
  --fg-inverse-2: var(--ink-300); /* secondary on --bg-inverse — 13.73:1 */
  --fg-inverse-3: var(--ink-400); /* tertiary / meta on --bg-inverse — 7.87:1 */
  /* Legacy alias of --accent-fg, kept because consumers import it by name.
     "signal" was the old word for the accent fill, and the pairing is the same
     one: the foreground the accent fill carries. New code uses --accent-fg. */
  --fg-on-signal: var(--accent-fg);

  /* --- Targets -----------------------------------------------------------
     Hit areas, as tokens rather than as a number retyped in thirty
     stylesheets. Touch targets are scoped, not blanket: --target-min is the
     absolute AA floor SC 2.5.8 sets and is what a dense inline affordance (a
     chip's remove button, a calendar day, a rating star) may shrink to, and
     is the floor it is *measured* against there and everywhere else;
     --target is what a primary control gets, and what every primary control
     gets under data-platform="mobile" — the dense affordances are excluded
     from that floor by name rather than inflated to it, because they are
     already held to --target-min (see the floor at the foot of this file);
     --target-lg is for the one action a thumb reaches for without looking.
     An expanded hit area must never overlap a sibling.
     ---------------------------------------------------------------------- */
  --target-min: 24px; /* absolute AA floor, SC 2.5.8 — the dense tier, on every platform */
  --target: 44px; /* primary controls, and every primary control on mobile */
  --target-lg: 56px; /* thumb-zone primary action */

  /* --- Safe areas --------------------------------------------------------
     The notch, the home indicator and the rounded corners, as four tokens
     that resolve to 0px on every device that has none. Reading env() through
     a token rather than inline is what lets a sticky footer or a full-bleed
     sheet be written once: `padding-bottom: calc(var(--space-4) +
     var(--safe-b))` is correct on a phone and unchanged on a desktop.
     ---------------------------------------------------------------------- */
  /* The @kind annotations are load-bearing, not decoration. The classifier in
     @elirobinson/ai-patterns reads a token's kind off its resolved value, and
     `env(...)` matches neither its colour pattern nor its length pattern — so
     without these four the insets file under "other" and stop being offered
     where a spacing value is picked. The annotation lives here because the
     classification is the token's to state, not the classifier's to guess. */
  --safe-t: env(safe-area-inset-top, 0px); /* @kind spacing */
  --safe-r: env(safe-area-inset-right, 0px); /* @kind spacing */
  --safe-b: env(safe-area-inset-bottom, 0px); /* @kind spacing */
  --safe-l: env(safe-area-inset-left, 0px); /* @kind spacing */

  /* The wash behind a modal, a sheet or a drawer. Pinned to black at both
     ends rather than themed to the page: a scrim's job is to push everything
     behind it back, and a scrim that turned white on a light page would push
     it forward instead. Only the opacity moves — a dark page needs more of it
     to separate the same two planes. */
  --scrim: oklch(0% 0 0 / 0.56);

  /* ======================================================================
     TYPE — families

     Each family reads a `--ds-font-*-override` first and falls back to the
     stack below, so a consumer can re-point a family without touching this
     file and without reasoning about the cascade:

       :root {
         --ds-font-sans-override: var(--font-geist-sans);
         --ds-font-mono-override: var(--font-geist-mono);
       }

     This exists because the family is sometimes the framework's to supply,
     not ours. `next/font` never exposes a family under its real name — it
     generates a hashed one (`__Geist_e8ce0c`) and hands it over through a CSS
     variable — so the literal 'Geist' below matches nothing it loaded and the
     page silently renders in ui-sans-serif.

     The hook works from any cascade layer *because nothing here declares
     these three properties*. This stylesheet is unlayered, and an unlayered
     declaration beats a layered one at any origin, so a consumer overriding
     --font-sans itself from inside `@layer base` silently loses. Overriding
     --ds-font-sans-override has nothing to lose to. Keep it that way:
     never declare a --ds-font-*-override in this file.
     ====================================================================== */
  --font-sans: var(
    --ds-font-sans-override,
    'Geist',
    ui-sans-serif,
    system-ui,
    -apple-system,
    'Segoe UI',
    Roboto,
    sans-serif
  );
  --font-display: var(
    --ds-font-display-override,
    'Geist',
    ui-sans-serif,
    system-ui,
    -apple-system,
    sans-serif
  );
  --font-mono: var(
    --ds-font-mono-override,
    'JetBrains Mono',
    ui-monospace,
    'SF Mono',
    Menlo,
    Consolas,
    monospace
  );

  /* Type scale — a perfect-fourth-ish ramp (1.25), tuned for tech UI */
  --fs-3xs: 10px;
  --fs-2xs: 11px;
  --fs-xs: 12px;
  --fs-sm: 14px;
  --fs-md: 16px; /* base */
  --fs-lg: 18px;
  --fs-xl: 20px;
  --fs-2xl: 24px;
  --fs-3xl: 30px;
  --fs-4xl: 38px;
  --fs-5xl: 48px;
  --fs-6xl: 64px;
  --fs-7xl: 84px;
  --fs-8xl: 112px;

  /* Line-heights */
  --lh-tight: 1.05;
  --lh-snug: 1.2;
  --lh-normal: 1.5;
  --lh-relaxed: 1.65;

  /* Tracking */
  --tr-tight: -0.025em;
  --tr-snug: -0.015em;
  --tr-normal: 0em;
  --tr-wide: 0.04em;
  --tr-caps: 0.08em;

  /* Weights */
  --fw-light: 300;
  --fw-regular: 400;
  --fw-medium: 500;
  --fw-semibold: 600;
  --fw-bold: 700;
  --fw-black: 800;

  /* ======================================================================
     SPACING — 4px base, 8pt-friendly
     ====================================================================== */
  --space-0: 0;
  --space-px: 1px;
  --space-1: 4px;
  --space-2: 8px;
  --space-3: 12px;
  --space-4: 16px;
  --space-5: 20px;
  --space-6: 24px;
  --space-7: 32px;
  --space-8: 40px;
  --space-9: 48px;
  --space-10: 64px;
  --space-11: 80px;
  --space-12: 96px;
  --space-13: 128px;
  --space-14: 160px;
  --space-15: 192px;

  /* ======================================================================
     RADII — sharp by default. We're tech-forward, not pillowy.
     ====================================================================== */
  --radius-none: 0;
  --radius-xs: 2px;
  --radius-sm: 4px; /* default for inputs, buttons */
  --radius-md: 6px; /* cards */
  --radius-lg: 10px; /* modals */
  --radius-xl: 16px; /* heroes */
  --radius-pill: 999px; /* tags only */

  /* ======================================================================
     SHADOWS — restrained. Borders do most of the work.
     ====================================================================== */
  --shadow-xs: 0 1px 0 0 oklch(0% 0 0 / 0.04);
  --shadow-sm: 0 1px 2px 0 oklch(0% 0 0 / 0.06), 0 0 0 1px oklch(0% 0 0 / 0.04);
  --shadow-md: 0 4px 12px -2px oklch(0% 0 0 / 0.08), 0 0 0 1px oklch(0% 0 0 / 0.04);
  --shadow-lg: 0 12px 32px -8px oklch(0% 0 0 / 0.14), 0 0 0 1px oklch(0% 0 0 / 0.05);
  --shadow-xl: 0 24px 56px -12px oklch(0% 0 0 / 0.2), 0 0 0 1px oklch(0% 0 0 / 0.05);

  /* Inner shadow for inset wells */
  --shadow-inset: inset 0 1px 0 0 oklch(0% 0 0 / 0.04);

  /* Focus ring — uniform across the system */
  --shadow-focus: 0 0 0 2px var(--bg), 0 0 0 4px var(--focus-ring);

  /* ======================================================================
     MOTION
     ====================================================================== */
  --ease-out: cubic-bezier(0.22, 1, 0.36, 1);
  --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
  --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);

  --dur-instant: 80ms;
  --dur-fast: 140ms;
  --dur-normal: 220ms;
  --dur-slow: 420ms;

  /* ======================================================================
     LAYOUT
     ====================================================================== */
  --container-sm: 640px;
  --container-md: 768px;
  --container-lg: 1024px;
  --container-xl: 1280px;
  --container-2xl: 1440px;
  /* Quantised to 4px, not because the gutter needs to be a multiple of four,
     but so it is always a whole number of pixels. Bare 4vw is 51.2px at a
     1280px viewport, and that fraction does not stay local: it becomes the
     origin of every centred shell, so the grid track after it lands on
     853.625px and every full-width rule, table and code block inside it has
     partially-covered pixels at both ends. Those partial coverages round
     inconsistently between runs, which showed up as 1-4px snapshot diffs on a
     rotating set of pages — the interior always identical, only the end caps
     moving. round() keeps the gutter responsive and removes the fraction at
     the source. max() still wins below ~500px, where the flat 20px applies. */
  --gutter: max(20px, round(4vw, 4px));

  /* Z-index ladder */
  --z-base: 0;
  --z-raised: 10;
  --z-sticky: 100;
  --z-overlay: 1000;
  --z-modal: 1100;
  --z-toast: 1200;
  --z-tooltip: 1300;
}

/* ==========================================================================
   Dark mode — opt-in via [data-theme="dark"]
   --------------------------------------------------------------------------
   `.dark` is accepted as a compatibility alias because most theme switchers
   default to a class strategy. next-themes in particular defaults to
   `attribute="class"`, so wiring it up without `attribute="data-theme"` used
   to leave dark mode silently doing nothing. Either convention works now;
   `[data-theme="dark"]` remains the one the system documents.
   ========================================================================== */
[data-theme='dark'],
.dark {
  --bg: var(--ink-1000);
  --bg-subtle: var(--ink-950);
  --bg-muted: var(--ink-900);
  --bg-inverse: var(--ink-0);

  --surface: var(--ink-950);
  --surface-2: var(--ink-900);
  --surface-3: var(--ink-800);

  --border: oklch(100% 0 0 / 0.1);
  --border-strong: oklch(100% 0 0 / 0.18);
  --border-inverse: var(--ink-200);
  --border-control: oklch(100% 0 0 / 0.42); /* 3.95:1 over --bg */

  --fg: var(--ink-0);
  --fg-1: var(--ink-0);
  --fg-2: var(--ink-300);
  --fg-3: var(--ink-400);
  --fg-4: var(--ink-500);
  --fg-disabled: var(--ink-400); /* 7.87:1 */
  --fg-inverse: var(--ink-1000);
  --fg-inverse-2: var(--ink-600); /* secondary on --bg-inverse — 8.45:1 */
  --fg-inverse-3: var(--ink-500); /* tertiary / meta on --bg-inverse — 4.85:1 */

  /* The wash behind an overlay, heavier on an ink page. The same 56% that
     separates a modal from a white page leaves a dark one looking merely
     dimmed — the plane behind it is already near-black, so there is less
     luminance to take away. Only the alpha moves; the colour stays black. */
  --scrim: oklch(0% 0 0 / 0.72);
}

/* ==========================================================================
   SEMANTIC TYPE — class-based shortcuts. Use these in components.
   ========================================================================== */
.t-display-1 {
  font-family: var(--font-display);
  font-weight: var(--fw-semibold);
  font-size: clamp(48px, 7vw, var(--fs-8xl));
  line-height: var(--lh-tight);
  letter-spacing: var(--tr-tight);
  color: var(--fg);
}
.t-display-2 {
  font-family: var(--font-display);
  font-weight: var(--fw-semibold);
  font-size: clamp(38px, 5.5vw, var(--fs-7xl));
  line-height: var(--lh-tight);
  letter-spacing: var(--tr-tight);
  color: var(--fg);
}
.t-h1 {
  font-family: var(--font-sans);
  font-weight: var(--fw-semibold);
  font-size: var(--fs-5xl);
  line-height: var(--lh-tight);
  letter-spacing: var(--tr-tight);
  color: var(--fg);
}
.t-h2 {
  font-family: var(--font-sans);
  font-weight: var(--fw-semibold);
  font-size: var(--fs-4xl);
  line-height: var(--lh-snug);
  letter-spacing: var(--tr-snug);
  color: var(--fg);
}
.t-h3 {
  font-family: var(--font-sans);
  font-weight: var(--fw-semibold);
  font-size: var(--fs-3xl);
  line-height: var(--lh-snug);
  letter-spacing: var(--tr-snug);
  color: var(--fg);
}
.t-h4 {
  font-family: var(--font-sans);
  font-weight: var(--fw-semibold);
  font-size: var(--fs-2xl);
  line-height: var(--lh-snug);
  letter-spacing: var(--tr-snug);
  color: var(--fg);
}
.t-h5 {
  font-family: var(--font-sans);
  font-weight: var(--fw-semibold);
  font-size: var(--fs-xl);
  line-height: var(--lh-snug);
  color: var(--fg);
}
.t-lead {
  font-family: var(--font-sans);
  font-weight: var(--fw-regular);
  font-size: var(--fs-xl);
  line-height: var(--lh-relaxed);
  color: var(--fg-2);
}
.t-body {
  font-family: var(--font-sans);
  font-weight: var(--fw-regular);
  font-size: var(--fs-md);
  line-height: var(--lh-relaxed);
  color: var(--fg);
}
.t-body-sm {
  font-family: var(--font-sans);
  font-weight: var(--fw-regular);
  font-size: var(--fs-sm);
  line-height: var(--lh-relaxed);
  color: var(--fg-2);
}
.t-caption {
  font-family: var(--font-sans);
  font-weight: var(--fw-regular);
  font-size: var(--fs-xs);
  line-height: var(--lh-normal);
  color: var(--fg-3);
}
.t-eyebrow {
  font-family: var(--font-mono);
  font-weight: var(--fw-medium);
  font-size: var(--fs-xs);
  line-height: 1;
  letter-spacing: var(--tr-caps);
  text-transform: uppercase;
  color: var(--fg-2);
}
.t-mono {
  font-family: var(--font-mono);
  font-weight: var(--fw-regular);
  font-size: var(--fs-sm);
  line-height: var(--lh-normal);
  color: var(--fg);
  font-variant-ligatures: none;
}
.t-code {
  font-family: var(--font-mono);
  font-weight: var(--fw-regular);
  font-size: 0.92em;
  background: var(--bg-muted);
  border: 1px solid var(--border);
  border-radius: var(--radius-xs);
  padding: 0.1em 0.35em;
}

/* ==========================================================================
   Sensible defaults
   ========================================================================== */
*,
*::before,
*::after {
  box-sizing: border-box;
}
html,
body {
  margin: 0;
  padding: 0;
}
body {
  font-family: var(--font-sans);
  font-size: var(--fs-md);
  line-height: var(--lh-relaxed);
  color: var(--fg);
  background: var(--bg);
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}

/* Native form controls do not inherit `font-family`. The UA stylesheet gives
   them Arial (monospace for <textarea>), so every control that sets
   `font-size` and nothing else typesets REAL WORDS in a different face from
   the label beside it. Five shipped controls did: .ds-search-field__input —
   which is what the user TYPES — .ds-pagination__item,
   .ds-segmented-control__item, .ds-accordion__trigger and
   .ds-date-picker__day. #167, found from the measurement in #146 that put a
   chip's label and its control on baselines 1.91px apart in two different
   typefaces.

   THE LONGHAND, AND THE LAYER, ARE BOTH DELIBERATE. Measured in a browser in
   form-font-cascade.test.mjs; the short version:

   Layered, for exactly the reason the `a` rule below is. An UNLAYERED
   `button { font-family: inherit }` is (0,0,1) but unlayered beats every
   layer, so it silently ate a consumer's `font-mono` and rendered the button
   in Geist — issue #112 again in a new spelling. A bare-element rule that
   paints must lose to anything stating an intent. In `base` it does, and our
   own component rules still win because @elirobinson/react ships them
   unlayered.

   `font-family`, NOT the `font` shorthand, and this is a deliberate departure
   from Tailwind's preflight rather than an oversight. The shorthand also
   resets line-height, which none of these controls declares — so it reaches
   every native control in the system, not just the five with the wrong face.
   Measured on the real components: .ds-input and .ds-select 44 -> 49.09px,
   .ds-textarea 64 -> 72.19px, .ds-accordion__trigger 44 -> 47.09px. This rule
   changes the FACE and nothing else; the only box that moves at all is
   .ds-segmented-control__item, 0.53px wider because Geist sets its label
   slightly differently from Arial.

   WHAT THAT COSTS, STATED PLAINLY: preflight resets these elements with the
   shorthand, so a consumer wired the way tailwind.css prescribes renders these
   controls with the inherited line-height while this repo's own docs — which
   ship no preflight — do not. That divergence is real, it predates this rule,
   and this rule does not close it. It is accepted knowingly: the five wrong
   typefaces are the bug, and buying the line-height too would move layout
   across most of the library for something nobody reported.
   form-font-cascade.test.mjs measures exactly where the two still differ, so
   the divergence is recorded rather than forgotten.

   The selector coverage is preflight's — <textarea>, <optgroup> and
   ::file-selector-button included, though none is among the audited five —
   because "which elements fail to inherit" has one answer and it is not the
   subset that happened to have a bug filed. Textarea and Select are shipped
   components.

   Every control listed sets its own `font-size` (and Accordion its own
   `font-weight`) in an unlayered component rule, so this changes face and
   nothing else — never the type ramp. */
@layer base {
  button,
  input,
  optgroup,
  select,
  textarea {
    font-family: inherit;
  }

  /* SPLIT FROM THE LIST ABOVE ON PURPOSE, and it must stay split. An
     unrecognised selector invalidates the ENTIRE rule it appears in, not just
     its own entry in the list — measured in Chromium: with one bogus
     pseudo-element added to the list, a <button> went back to the UA's Arial
     while `body` was Georgia. So on any engine that does not parse
     ::file-selector-button, keeping it in the list above would silently take
     all five audited controls down with it, with no error anywhere. Tailwind's
     preflight ships it in one list because a build step compiles preflight
     against declared targets; this file is raw CSS handed to whatever engine a
     consumer has.

     The cost of the split is nothing — two rules, identical effect — and the
     failure it removes is the silent-total kind this package has already been
     bitten by once, in the fonts.css @import story at the top of this file. */
  ::file-selector-button {
    font-family: inherit;
  }
}

/* The selection highlight is the brand fill, and it has to be *the* brand
   fill. Hardcoding --signal-500 made it Miltinson Amber under every palette,
   including the one whose signal ramp is teal — and it pinned the foreground
   to ink, which is wrong the moment a palette's accent carries white text. */
::selection {
  background: var(--accent);
  color: var(--accent-fg);
}

/* The one layered rule in this file. See the `@layer base;` statement at the
   top for why the layer is named `base` and what fixes its order.

   Layered, because unlayered wins over every layer regardless of specificity
   and Tailwind puts all of its utilities in `@layer utilities` — so as an
   unlayered rule this silently beat `text-accent-foreground`,
   `text-muted-foreground` and every other `text-*` a consumer put on an
   anchor. It shipped white-on-#14b8a6 CTAs at ~2.1:1 that had asked, in
   markup, for the palette's own `--accent-fg`. There was no way to fix that
   from a consumer stylesheet either: an unlayered override of theirs beats
   the utilities too, so `a { color: inherit }` just moved the failure. #112.

   In a layer the rule still does its one job — an anchor nobody has styled
   is coloured and underlined — and loses to anything that states an intent.
   That is the whole intent, and it is now enforced by the layer rather than
   by specificity arithmetic. */
@layer base {
  a {
    color: var(--link);
    text-decoration: underline;
    text-underline-offset: 0.2em;
    text-decoration-thickness: 1px;
  }
  a:hover {
    color: var(--link-hover);
  }
}

/* Anything that paints its own background owns its text colour. A global
   a:hover hue shift over a filled surface is the amber-on-amber bug:
   `a:hover` was (0,1,1) and `.ds-button--accent` is (0,1,0), so an
   `<a class="ds-button ds-button--accent">` kept its amber fill and had its
   label repainted --link-hover on hover — amber on amber, 2.31:1, failing
   SC 1.4.3. The variant's own :hover only set background-color, so nothing
   in the component library outranked the element rule. These selectors are
   (0,2,1) and (0,2,2), which did.

   The layer above now carries that guarantee on its own: these rules are
   unlayered and the `a` rules are not, so they win whatever the specificity,
   and so does `.ds-button--accent` in the react package. They are kept
   anyway, because they are not purely defensive — an anchor sitting inside a
   filled surface still matches the layered `a` rule and nothing else, so
   --link-on-fill is the only thing painting it, and the :hover block's
   2px underline is a real affordance rather than a repair. Deleting them
   would change rendering, not just remove redundancy. */
.ds-button a,
.ds-badge a,
.ds-chip a,
.ds-alert a,
.ds-toast a,
.ds-pagination__item--active a,
[data-on-fill] a,
[data-on-fill] a:visited {
  color: var(--link-on-fill);
}
.ds-button a:hover,
.ds-badge a:hover,
.ds-chip a:hover,
.ds-alert a:hover,
.ds-toast a:hover,
.ds-pagination__item--active a:hover,
[data-on-fill] a:hover {
  color: var(--link-on-fill);
  /* The hover affordance has to survive losing the hue shift. */
  text-decoration-thickness: 2px;
}
/* A control that happens to be an anchor is a control, not a link. */
a.ds-button,
a.ds-button:hover,
a.ds-button:visited,
a.ds-chip,
a.ds-chip:hover,
a.ds-badge,
a.ds-badge:hover,
a.ds-pagination__item,
a.ds-pagination__item:hover {
  text-decoration: none;
}

:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 2px;
  border-radius: var(--radius-xs);
}

/* Every control clears 44px on a phone, whatever the component sized it at —
   every control the system did not deliberately draw dense. SC 2.5.8 asks 24px
   of any pointer target; a finger is not a mouse, and a control sized for a
   mouse is the one that gets missed at arm's length. This is a floor, not a
   size: min-height leaves a control free to be taller.

   `input:not([type='range'])` because a range input draws its own track and
   thumb, and a min-height on it stretches the track rather than the thumb.

   Why the dense affordances are excluded rather than floored
   ----------------------------------------------------------
   The trailing `:not()` names the system's dense inline affordances — a chip,
   a chip's remove glyph, a search field's clear, a rating star, a calendar
   day, a `size="sm"` button, and anything a page marks
   `data-touch-target="dense"` — and holds them out of this floor entirely.

   That is not an exemption from the contract, because the contract has a floor
   for them: @elirobinson/ai-patterns measures everything matching
   DENSE_AFFORDANCE_SELECTOR against 24x24 (--target-min, SC 2.5.8 AA) and
   reports a miss as `touch-target-dense`. A dense control is *measured*, and it
   is measured against the standard's own number. Stretching it to 44px here
   would be the third thing — neither the dense scale it was drawn at nor the
   measurement that keeps it honest, just a taller box. A chip is 32px because
   that is MUI's Chip; a remove glyph is 22px painted because that is MUI's
   delete icon, and it reaches 24x24 through a bounded overlay rather than by
   growing. Inflating either on a phone throws away the scale and buys nothing
   the dense floor was not already buying.

   So the two floors divide cleanly, and this is the whole of it: a control the
   system draws at the primary scale gets 44px on a phone, and a control the
   system draws dense gets measured at 24x24 everywhere. Nothing is unmeasured
   in either case.

   The exclusion list is duplicated from DENSE_AFFORDANCE_SELECTOR, which is
   JavaScript and cannot be imported into a stylesheet. The two are pinned
   together by a test — packages/ai-patterns/src/testing/dense-selector-parity.test.mjs
   reads this file and fails if a class is added to one and not the other —
   because a silent drift here is a control that is floored to 44px on a phone
   while the contract measures it at 24, which is exactly the disagreement the
   parity note below is about.

   `:not(:where(…))` and not a bare `:not(…)`: `:where()` has zero specificity,
   so the exclusion contributes none. That matters because this list will grow.
   A bare `:not()` would take the weight of its heaviest argument, so adding a
   class to it would silently re-weight the floor against every component rule
   it competes with — a specificity change disguised as a list edit. Wrapped in
   `:where()`, the exclusion changes only *which* elements match and provably
   nothing about how the rule competes. */
:root[data-platform='mobile']
  :is(button, [role='button'], a.ds-button, input:not([type='range']), select):not(
    :where(
      [data-touch-target='dense'],
      .ds-button--sm,
      .ds-chip,
      .ds-chip__remove,
      .ds-search-field__clear,
      .ds-rating__button,
      .ds-date-picker__day
    )
  ) {
  min-height: var(--target);
}

/* The same floor, reached without JavaScript — the element-level half of what
   mobile.css does for the token values. That file repeats its declarations
   under `@media (max-width: 480px) and (pointer: coarse)` so a responsive site
   need not swap the attribute at a breakpoint, and the floor has to follow it
   or a responsive site gets the coarser radii and the floored type ramp
   without the touch targets — which is the half that is an accessibility
   requirement rather than a refinement.

   The query is duplicated rather than shared because these are element rules
   and mobile.css declares custom properties; a `--target` that changed under
   the query instead would move every explicit `min-height: var(--target)` in
   the component library at a breakpoint, which is a much larger claim than
   "a phone-sized coarse-pointer viewport gets 44px controls".

   `:root:not([data-platform='mobile'])` is what makes it *the same* floor, and
   it is a specificity fix, not a scoping one. This half used to lead with a
   bare `button` — (0,0,1), which loses to `.ds-chip` and `.ds-button--sm`
   (0,1,0) — while the attribute half above led with `:root[data-platform=
   'mobile'] button`, (0,2,1), and won. So the two halves of a rule whose own
   comment calls them the same floor disagreed, and disagreed about exactly the
   dense affordances: a responsive coarse-pointer phone got a 32px
   `button.ds-chip` and a 36px `--sm` button, and the same page with
   data-platform="mobile" set got 44px for both.

   Both halves now carry the same dense exclusion, so they agree by matching the
   same elements rather than by out-weighing the same rules — which is the
   sturdier kind of agreement, and the one that survives a component adding a
   class. The responsive half is the one that was already right about the dense
   controls; the attribute half is the one that changed to match it.

   Parity, worked rather than asserted. Both halves are one compound selector
   with an identical suffix, so the whole question is the two prefixes:

     :root[data-platform='mobile']       (0,1,0) + (0,1,0) = (0,2,0)
     :root:not([data-platform='mobile']) (0,1,0) + (0,1,0) = (0,2,0)

   `:root` is a pseudo-class, so it is (0,1,0) and not (0,0,1) — the note on
   #116 counted the attribute half as (0,1,1) for that reason, and it is
   (0,2,1). `:not()` takes the specificity of its most specific argument, and
   `[data-platform='mobile']` is an attribute selector, (0,1,0). The suffix adds
   `:is(button, [role='button'], a.ds-button, input:not([type='range']),
   select)` — `:is()` likewise takes its heaviest argument, which is `a.ds-button`
   at (0,1,1) — and `:not(:where(…))`, which is (0,0,0) by construction. So each
   half is (0,2,0) + (0,1,1) + (0,0,0) = **(0,3,1)**, identical, and verified in
   a browser rather than counted: packages/tokens/src/mobile-floor.test.mjs
   resolves both halves against a synthetic competitor of known weight and
   asserts they land on the same rung.

   `:is()` also levels the five subjects, which the old form did not: `button`
   and `select` were (0,2,1) while `a.ds-button` and the `input` were (0,3,1),
   so "the rule's specificity" was really five numbers. One is easier to keep
   honest, and the leveling was measured to change no computed min-height
   anywhere in the library.

   The `:not([data-platform='mobile'])` also states the division of labour: this
   half covers the pages that have not set the attribute, and the half above
   covers the ones that have. Nothing matches both, and nothing matches
   neither. */
@media (max-width: 480px) and (pointer: coarse) {
  :root:not([data-platform='mobile'])
    :is(button, [role='button'], a.ds-button, input:not([type='range']), select):not(
      :where(
        [data-touch-target='dense'],
        .ds-button--sm,
        .ds-chip,
        .ds-chip__remove,
        .ds-search-field__clear,
        .ds-rating__button,
        .ds-date-picker__day
      )
    ) {
    min-height: var(--target);
  }
}

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
  }
}

/* ==========================================================================
   ChatMessage
   ========================================================================== */

/* `auto` and not a number: the avatar column is whatever the avatar frame is,
   and the avatar frame is an Avatar. Writing the size here would be a second
   place the avatar scale lives, which is the bug this column used to have. */
.ds-chat-message {
  align-items: start;
  display: grid;
  gap: var(--space-3);
  grid-template-columns: auto minmax(0, 1fr);
}

/* The frame carries `ds-avatar ds-avatar--md` as well as this class, so the
   circle — 40px, --bg-muted, pill radius, centred, clipped — comes from
   Avatar.css and is not restated here. Only two things are ChatMessage's own:
   the hairline edge, and (below) the mark colour.

   This avatar used to hand-roll a 44px box, on the claim that "44x44 is the
   floor for an avatar in this system". There is no such floor. The 44px floor
   in docs/agents/components.md is scoped to PRIMARY INTERACTIVE CONTROLS; this
   frame is aria-hidden and not focusable, so it is a decorative mark and sits
   on the ordinary Avatar scale (32/40/56) like every other avatar.

   The edge is an outline over the fill rather than a solid ink fill: a pure-ink
   fill is the same colour as a dark page and the mark inside it disappears,
   while an outline survives both themes. --border-control, because the edge is
   what says where the circle is: 3.26:1 light / 4.00:1 dark on --bg-muted. */
.ds-chat-message__avatar {
  border: 1px solid var(--border-control);
  flex: none;
}

.ds-chat-message__mark {
  align-items: center;
  display: flex;
  font-family: var(--font-sans);
  font-size: var(--fs-sm);
  font-weight: var(--fw-semibold);
  height: 100%;
  justify-content: center;
  width: 100%;
}

/* The assistant mark is text a reader reads at 14px semibold, so it needs a
   token cleared for text and not a fill: --accent is 2.53:1 against the page
   under ember, which is a fill floor, not a text one.

   The fallback is --status-warning-fg (6.24:1 light, 11.51:1 dark on the
   frame's --bg-muted), NOT the brand's readable amber. That looks like a near-miss
   and is deliberate. --accent-ink would be legible too, but it swaps with the
   palette — amber under ember, teal under slate — so the assistant's mark
   would silently re-hue with the brand, and the mark is an identity, not a
   brand accent. --status-* is the palette-independent family, so this reads
   the same under every palette; it is identical in ember and slate.

   --product-signal-fg is the override for a product that wants its own hue,
   which is the supported way to change this — one token, not a fork. */
.ds-chat-message--received .ds-chat-message__mark {
  color: var(--product-signal-fg, var(--status-warning-fg));
}

/* The settled turn. Not opacity: 0.72 — that drops body copy on --fg to about
   3.4:1. --fg-2 reads as settled at 8.45:1 and follows the theme. */
.ds-chat-message--sent {
  color: var(--fg-2);
}

.ds-chat-message__body {
  display: grid;
  gap: var(--space-2);
  min-width: 0;
}

.ds-chat-message__meta {
  align-items: baseline;
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
}

.ds-chat-message__name {
  color: var(--fg);
  font-family: var(--font-sans);
  font-size: var(--fs-xs);
  font-weight: var(--fw-semibold);
}

.ds-chat-message__time {
  color: var(--fg-3);
  font-family: var(--font-sans);
  font-size: var(--fs-2xs);
  letter-spacing: var(--tr-wide);
}

.ds-chat-message__content {
  font-family: var(--font-sans);
  font-size: var(--fs-sm);
  line-height: var(--lh-normal);
}

.ds-chat-message__actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
  margin-top: var(--space-1);
}

/* ==========================================================================
   ChatThread
   ========================================================================== */

.ds-chat-thread {
  background: var(--bg);
  color: var(--fg);
  display: flex;
  flex-direction: column;
  gap: var(--space-5);
  overflow-y: auto;
  padding: var(--space-5);

  /* `scroll-behavior` inherits, and `html { scroll-behavior: smooth }` is in
     most CSS resets. Left to inherit, that turns `followNewMessages` from an
     instant jump into an animation inside a live region — motion the component
     never opted into and would then owe `prefers-reduced-motion` care. Declared
     here so the thread scrolls the same way in every app that imports it. */
  scroll-behavior: auto;
}

/* ==========================================================================
   StreamingCaret
   ========================================================================== */

.ds-streaming-caret {
  /* One blink is two --dur-slow beats. The duration is derived from the motion
     scale rather than a literal so a product retuning the scale retunes this. */
  animation: ds-streaming-caret-blink calc(var(--dur-slow) * 2) var(--ease-in-out) infinite;
  /* The caret is a non-text state graphic, so SC 1.4.11 asks 3:1 against the
     surface behind it. --accent is --signal-500 and measures 2.53:1 on --bg,
     which misses; --accent-press is --signal-600 at 3.69:1, which clears.
     --product-signal lets a product substitute its own hue for the same job. */
  background: var(--product-signal, var(--accent-press));
  border-radius: var(--radius-xs);
  display: inline-block;
  height: 1em;
  margin-inline-start: var(--space-px);
  vertical-align: text-bottom;
  width: 0.5em;
}

@keyframes ds-streaming-caret-blink {
  0%,
  45% {
    opacity: 1;
  }
  55%,
  100% {
    opacity: 0.2;
  }
}

/* Reduced motion stops the blink and leaves the caret solid — never hidden.
   It is the only signal that a message is still being written, so removing the
   animation must not remove the state. */
@media (prefers-reduced-motion: reduce) {
  .ds-streaming-caret {
    animation: none;
    opacity: 1;
  }
}

/* ==========================================================================
   Avatar
   ========================================================================== */

.ds-avatar {
  align-items: center;
  background: var(--bg-muted); /* the initials fallback has to follow the theme */
  border-radius: var(--radius-pill);
  display: inline-flex;
  justify-content: center;
  overflow: hidden;
}

.ds-avatar--sm {
  height: 32px;
  width: 32px;
}
.ds-avatar--md {
  height: 40px;
  width: 40px;
}
.ds-avatar--lg {
  height: 56px;
  width: 56px;
}

.ds-avatar__image {
  height: 100%;
  object-fit: cover;
  width: 100%;
}

.ds-avatar__fallback {
  color: var(--fg-2);
  font-family: var(--font-mono);
  font-size: var(--fs-xs);
  font-weight: var(--fw-medium);
  letter-spacing: 0.04em;
  text-transform: uppercase;
}

.ds-avatar--lg .ds-avatar__fallback {
  font-size: var(--fs-sm);
}

/* ==========================================================================
   Badge
   ========================================================================== */

.ds-badge {
  border-radius: var(--radius-pill);
  display: inline-block;
  font-family: var(--font-mono);
  font-size: 11px;
  font-weight: var(--fw-medium);
  letter-spacing: 0.06em;
  line-height: 1;
  padding: var(--space-1) var(--space-3);
  text-transform: uppercase;
}

/* --bg-muted, not --ink-100: --fg-2 follows the theme and the fill did not,
   so in dark mode this was ink-300 text on a near-white chip at 1.37:1. */
.ds-badge--default {
  background: var(--bg-muted);
  color: var(--fg-2); /* 7.57:1 light, 13.27:1 dark */
}

/* The brand tints, which now invert instead of being a fixed pair. They were
   self-consistent on any page — 8.05:1 and 11.84:1 — and still wrong in dark
   mode: a 94%-light chip beside a `default` badge that had inverted. */
.ds-badge--signal {
  background: var(--accent-tint);
  color: var(--accent-ink); /* 8.96:1 light, 8.43:1 dark */
}

.ds-badge--anchor {
  background: var(--anchor-tint);
  color: var(--anchor-ink); /* 10.23:1 light, 9.45:1 dark */
}

/* The inverse pair, so a solid badge stays visible against either page —
   a fixed black chip on a black page was 1.00:1. */
.ds-badge--solid {
  background: var(--bg-inverse);
  color: var(--fg-inverse);
}

.ds-badge--outline {
  background: transparent;
  border: 1px solid var(--border-strong);
  color: var(--fg-2);
}

/* ==========================================================================
   Button
   --------------------------------------------------------------------------
   Every variant restates `color` in :hover and :active, which reads as
   redundant and is not. `<a class="ds-button ds-button--accent">` is a real
   and encouraged usage, and tokens.css's global `a:hover { color:
   var(--link-hover) }` is (0,1,1) where `.ds-button--accent` is (0,1,0). A
   variant whose :hover block only moves background-color therefore keeps its
   fill and lets the element rule repaint the label — amber on amber, 2.31:1,
   failing SC 1.4.3. The `:hover`/`:active` blocks below are (0,2,0), which
   outranks it. Restate `color` in every state you add.
   ========================================================================== */

.ds-button {
  align-items: center;
  border-radius: var(--radius-sm);
  border: 1px solid transparent;
  cursor: pointer;
  display: inline-flex;
  font-family: var(--font-sans);
  font-size: var(--fs-sm);
  font-weight: var(--fw-medium);
  gap: var(--space-2);
  justify-content: center;
  line-height: 1;
  min-height: 44px;
  padding: var(--space-3) var(--space-4);
  /* A control that happens to be an anchor is a control, not a link. */
  text-decoration: none;
  transition:
    background-color var(--dur-fast) var(--ease-out),
    border-color var(--dur-fast) var(--ease-out),
    transform var(--dur-fast) var(--ease-out);
}

.ds-button:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 2px;
}

.ds-button--sm {
  font-size: var(--fs-xs);
  min-height: 36px;
  padding: var(--space-2) var(--space-3);
}

.ds-button--lg {
  font-size: var(--fs-md);
  padding: var(--space-4) var(--space-5);
}

/* The high-contrast action is ink-on-background, which means it inverts with
   the theme — the same pairing @elirobinson/tokens/tailwind.css already
   documents as --color-primary/--color-primary-foreground. Fixed at
   --ink-1000 it was a black button on a black page, its fill 1.00:1 against
   the page and only the label betraying that a control was there. */
.ds-button--primary {
  background-color: var(--fg);
  color: var(--bg); /* 21:1 */
}

.ds-button--primary:hover {
  background-color: var(--fg-2);
  color: var(--bg); /* 8.45:1 light, 13.73:1 dark */
}
.ds-button--primary:active {
  background-color: var(--fg);
  color: var(--bg); /* 21:1 */
  transform: translateY(1px);
}

.ds-button--accent {
  background-color: var(--accent);
  color: var(--accent-fg); /* 8.30:1 */
}

.ds-button--accent:hover {
  background-color: var(--accent-hover);
  color: var(--accent-fg); /* 10.17:1 */
}
.ds-button--accent:active {
  background-color: var(--accent-press);
  color: var(--accent-fg); /* 5.69:1 */
  transform: translateY(1px);
}

.ds-button--secondary {
  background-color: transparent;
  border-color: var(--border-control); /* 3.64:1 — SC 1.4.11 */
  color: var(--fg);
}

.ds-button--secondary:hover {
  background-color: var(--bg-subtle);
  color: var(--fg); /* 20.06:1 */
}
.ds-button--secondary:active {
  background-color: var(--bg-muted);
  color: var(--fg);
  transform: translateY(1px);
}

.ds-button--ghost {
  background-color: transparent;
  color: var(--fg);
}

.ds-button--ghost:hover {
  background-color: var(--bg-subtle);
  color: var(--fg); /* 20.06:1 */
}
.ds-button--ghost:active {
  background-color: var(--bg-muted);
  color: var(--fg);
  transform: translateY(1px);
}

/* --bg-muted, not --ink-100: the fill has to follow the theme. Hard-coded,
   it painted a near-white button on a black page in dark mode, and the
   disabled label sat on it at 2.39:1. Now 4.34:1 light, 7.60:1 dark. */
.ds-button--disabled,
.ds-button:disabled {
  background-color: var(--bg-muted);
  border-color: transparent;
  color: var(--fg-disabled);
  cursor: not-allowed;
  transform: none;
}

.ds-button--disabled:hover,
.ds-button:disabled:hover,
.ds-button--disabled:active,
.ds-button:disabled:active {
  background-color: var(--bg-muted);
  color: var(--fg-disabled);
  transform: none;
}

/* ==========================================================================
   Checkbox
   ========================================================================== */

.ds-checkbox {
  align-items: center;
  /* The row is the label now, so the whole of it is clickable. */
  cursor: pointer;
  display: flex;
  gap: var(--space-3);
  min-height: 44px;
}

.ds-checkbox__input {
  /* --fg, not --ink-1000: accent-color paints the checked box itself, so a
     fixed black fill left a checked checkbox at 1.00:1 against a dark page —
     the same class as the tab underline and the switch track, missed only
     because `accent-color` is not spelled like the properties the sweep in
     scripts/component-css.test.mjs was reading. 21:1 in both themes now. */
  accent-color: var(--fg);
  cursor: pointer;
  height: 18px;
  width: 18px;
}

.ds-checkbox__input:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 2px;
}

.ds-checkbox__label {
  color: var(--fg);
  cursor: pointer;
  font-family: var(--font-sans);
  font-size: var(--fs-sm);
}

/* ==========================================================================
   Eyebrow
   ========================================================================== */

.ds-eyebrow {
  color: var(--fg-2);
  font-family: var(--font-mono);
  font-size: var(--fs-xs);
  font-weight: var(--fw-medium);
  letter-spacing: 0.16em;
  text-transform: uppercase;
}

/* ==========================================================================
   Kbd
   ========================================================================== */

.ds-kbd {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 20px;
  padding: 2px 6px;
  border-radius: var(--radius-sm);
  /* The key cap's edge is what makes it read as a key. 3.64:1 — SC 1.4.11. */
  border: 1px solid var(--border-control);
  background: var(--bg-subtle);
  font-family: var(--font-mono);
  font-size: var(--fs-2xs);
  color: var(--fg-2);
  box-shadow: var(--shadow-sm);
}

/* ==========================================================================
   Progress
   ========================================================================== */

/* The track is how far there is to go, so it is part of the reading, not a
   backdrop: at --ink-100 it was 1.12:1 against a light page and invisible.
   3.64:1 light, 3.94:1 dark — and the bar stays 5.77:1 against it. */
.ds-progress {
  background: var(--border-control);
  border-radius: var(--radius-pill);
  height: 8px;
  overflow: hidden;
  width: 100%;
}

.ds-progress__bar {
  background: var(--fg); /* was --ink-1000: a black bar on a black page */
  border-radius: var(--radius-pill);
  height: 100%;
  transition: width var(--dur-normal) var(--ease-out);
}

/* ==========================================================================
   Separator
   ========================================================================== */

.ds-separator {
  background: var(--border);
  flex-shrink: 0;
}

.ds-separator--horizontal {
  height: 1px;
  width: 100%;
}

.ds-separator--vertical {
  height: 100%;
  width: 1px;
}

/* ==========================================================================
   Skeleton
   ========================================================================== */

.ds-skeleton {
  animation: ds-skeleton-pulse 1.5s ease-in-out infinite;
  background: var(--bg-muted); /* a near-white block on a dark page otherwise */
  border-radius: var(--radius-sm);
  min-height: 16px;
}

@keyframes ds-skeleton-pulse {
  0%,
  100% {
    opacity: 1;
  }
  50% {
    opacity: 0.5;
  }
}

/* ==========================================================================
   Slider
   ========================================================================== */

.ds-slider {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  width: 100%;
}

.ds-slider__label {
  font-size: var(--fs-sm);
  color: var(--fg-2);
}

.ds-slider__input {
  -webkit-appearance: none;
  appearance: none;
  width: 100%;
  height: 44px;
  background: transparent;
}

.ds-slider__input::-webkit-slider-runnable-track {
  height: 4px;
  border-radius: var(--radius-pill);
  background: var(--border-control); /* the track delineates the range */
}

.ds-slider__input::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  margin-top: -8px;
  width: 20px;
  height: 20px;
  border-radius: 9999px;
  /* --accent is 2.53:1 on a light page; the thumb is the control itself.
     The 2px --surface ring is what separates thumb from track (9.69:1 thumb
     to ring, 3.64:1 ring to track), so keep it when changing either. */
  background: var(--accent-ink);
  border: 2px solid var(--surface);
  box-shadow: var(--shadow-sm);
}

.ds-slider__input::-moz-range-track {
  height: 4px;
  border-radius: var(--radius-pill);
  background: var(--border-control); /* the track delineates the range */
}

.ds-slider__input::-moz-range-thumb {
  width: 20px;
  height: 20px;
  border-radius: 9999px;
  /* --accent is 2.53:1 on a light page; the thumb is the control itself.
     The 2px --surface ring is what separates thumb from track (9.69:1 thumb
     to ring, 3.64:1 ring to track), so keep it when changing either. */
  background: var(--accent-ink);
  border: 2px solid var(--surface);
  box-shadow: var(--shadow-sm);
  cursor: pointer;
}

.ds-slider__input:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 2px;
}

/* ==========================================================================
   Spinner
   ========================================================================== */

.ds-spinner {
  display: inline-block;
  border-radius: 9999px;
  border: 2px solid var(--border);
  border-top-color: var(--accent);
  animation: ds-spin 0.7s linear infinite;
}

.ds-spinner--sm {
  width: 16px;
  height: 16px;
}

.ds-spinner--md {
  width: 24px;
  height: 24px;
}

.ds-spinner--lg {
  width: 40px;
  height: 40px;
  border-width: 3px;
}

@keyframes ds-spin {
  to {
    transform: rotate(360deg);
  }
}

/* ==========================================================================
   Switch
   ========================================================================== */

.ds-switch {
  align-items: center;
  /* The row is the label now, so the whole of it is clickable. */
  cursor: pointer;
  display: flex;
  gap: var(--space-3);
  min-height: 44px;
}

.ds-switch__input {
  appearance: none;
  /* The off state is the whole control: at --ink-200 the track was 1.24:1
     against the page and effectively invisible. 3.64:1 — SC 1.4.11. */
  background: var(--border-control);
  border-radius: var(--radius-pill);
  cursor: pointer;
  height: 24px;
  position: relative;
  transition: background-color var(--dur-fast) var(--ease-out);
  width: 44px;
}

/* The knob is --bg, not a fixed white: on a dark theme a white knob sat on a
   white-ish off track at 1.00:1 and vanished. 3.64:1 off, 21:1 on. */
.ds-switch__input::before {
  background: var(--bg);
  border-radius: var(--radius-pill);
  content: '';
  height: 18px;
  left: 3px;
  position: absolute;
  top: 3px;
  transition: transform var(--dur-fast) var(--ease-out);
  width: 18px;
}

/* Likewise the on state: --ink-1000 was a black track on a black page, so the
   checked state was invisible in dark mode. */
.ds-switch__input:checked {
  background: var(--fg); /* 21:1 against --bg in both themes */
}

.ds-switch__input:checked::before {
  transform: translateX(20px);
}

.ds-switch__input:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 2px;
}

.ds-switch__label {
  color: var(--fg);
  cursor: pointer;
  font-family: var(--font-sans);
  font-size: var(--fs-sm);
}

/* ==========================================================================
   Form fields
   ========================================================================== */

.ds-field {
  display: grid;
  gap: var(--space-2);
}

.ds-label {
  color: var(--fg);
  font-family: var(--font-sans);
  font-size: var(--fs-sm);
  font-weight: var(--fw-medium);
}

.ds-hint {
  color: var(--fg-3);
  font-family: var(--font-sans);
  font-size: var(--fs-xs);
}

.ds-hint--error {
  color: var(--status-danger-fg); /* 7.55:1 — the fill token is 4.79:1 */
}

.ds-input {
  background: var(--surface);
  /* The input's edge is the only thing telling a user where the field is:
     a control boundary at 3:1 under SC 1.4.11, not a decorative hairline. */
  border: 1px solid var(--border-control);
  border-radius: var(--radius-sm);
  color: var(--fg);
  font-family: var(--font-sans);
  font-size: var(--fs-sm);
  min-height: 44px;
  padding: var(--space-3) var(--space-3);
  width: 100%;
}

.ds-input:focus-visible {
  border-color: var(--fg); /* was --ink-1000: black-on-black when focused in dark */
  outline: 2px solid var(--focus-ring);
  outline-offset: 1px;
}

.ds-input--error {
  border-color: var(--status-danger);
}

.ds-textarea {
  min-height: 64px;
  resize: vertical;
}

.ds-select {
  appearance: none;
  background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 16 16' fill='none' stroke='%23666' stroke-width='1.5'><path d='M4 6l4 4 4-4'/></svg>");
  background-position: right var(--space-3) center;
  background-repeat: no-repeat;
  cursor: pointer;
  /* A <select>'s preferred width is its widest <option>, and in WebKit that
     width reaches the document: the control's own box still obeys width:100%
     and the <option> boxes still measure 0x0, yet documentElement.scrollWidth
     grows and the page scrolls sideways. Chromium's UA sheet computes
     overflow:clip here and is immune; WebKit computes `visible`, so iOS --
     WebKit only -- is the platform that shows it. Option text is user data
     (addresses, names), so no copy discipline bounds it.

     Measured in a consumer (house-warm, /app/open-houses/new, Mobile Safari at
     350x740, production build, a 58-character address): without this
     declaration the document is 439/350 while the <select> itself is 310px --
     width:100% plainly obeyed, no consumer width override, .ds-field grid
     intact. The one computed value that differs is this one. With it, 350/350.

     Note what that rules out: width:100% does NOT neutralise this, and a
     reading of it as belt-and-braces over that width is wrong. The leak is not
     downstream of the control's used width at all. See
     select-intrinsic-width.test.mjs, whose header records what that guard can
     and cannot reach. */
  overflow: hidden;
  padding-right: var(--space-8);
}

/* ==========================================================================
   Alert
   ========================================================================== */

.ds-alert {
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  display: grid;
  gap: var(--space-1);
  padding: var(--space-4) var(--space-5);
}

.ds-alert__title {
  font-family: var(--font-sans);
  font-size: var(--fs-sm);
  font-weight: var(--fw-semibold);
}

.ds-alert__description {
  font-family: var(--font-sans);
  font-size: var(--fs-sm);
  line-height: var(--lh-normal);
}

.ds-alert--default {
  background: var(--bg-subtle);
  color: var(--fg);
}

/* Each variant is a --status-*-tint fill with its --status-*-fg text. Both
   members follow the theme, which is the point: these used fixed light tints,
   so the moment the text token became themed the danger variant inverted into
   light-red-on-pink at 2.40:1. Change one and you change the other.

   The edge is --status-*-tint-edge, the hairline that belongs to the tint, and
   not --status-*, which is the fill. The fill is picked to be read against the
   page, so drawn as this border it fights the quiet surface it encloses — and
   for warning it is 1.87:1, a rule a reader cannot see at all. The edge here
   is decorative: the -fg text on the tint is what carries the reading, and it
   is the ratio measured on each variant below. */
.ds-alert--success {
  background: var(--status-success-tint);
  border-color: var(--status-success-tint-edge);
  color: var(--status-success-fg); /* 6.71:1 light, 9.42:1 dark */
}

.ds-alert--warning {
  background: var(--status-warning-tint);
  border-color: var(--status-warning-tint-edge);
  color: var(--status-warning-fg); /* 6.24:1 light, 9.85:1 dark */
}

.ds-alert--danger {
  background: var(--status-danger-tint);
  border-color: var(--status-danger-tint-edge);
  color: var(--status-danger-fg); /* 6.69:1 light, 6.63:1 dark */
}

.ds-alert--info {
  background: var(--status-info-tint);
  border-color: var(--status-info-tint-edge);
  color: var(--status-info-fg); /* 6.38:1 light, 7.89:1 dark */
}

/* ==========================================================================
   Breadcrumb
   ========================================================================== */

.ds-breadcrumb__list {
  align-items: center;
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
  list-style: none;
  margin: 0;
  padding: 0;
}

.ds-breadcrumb__item {
  align-items: center;
  display: flex;
  gap: var(--space-2);
}

.ds-breadcrumb__link {
  color: var(--fg-2);
  font-family: var(--font-sans);
  font-size: var(--fs-sm);
  text-decoration: none;
}

/* Breadcrumb links are nav items, so the 44px rule applies to them rather than
   the dense scale — but they were 34-38px wide and 23px tall. The hit area is
   grown with padding and taken straight back off with an equal negative margin:
   the border box a finger lands on is 44x45, while the margin box that lays the
   trail out is the width and height it always was. Each link is a flex item of
   .ds-breadcrumb__item, so its margins cancel against the flex line exactly.

   A positioned ::after overlay is the obvious alternative and was tried first.
   It fails for a non-obvious reason: the overlay needs `position: relative` on
   the link, that promotes the link into the positioned paint phase, and
   Chromium then drops LCD subpixel antialiasing for text it can no longer prove
   sits on an opaque background. Nothing moved, but every glyph in the trail went
   from colour-fringed to neutral grey — softer text across the whole component.
   Padding avoids the promotion entirely.

   The 5px of horizontal overhang a side sits inside the 8px gap to the
   separator, so a crumb still cannot reach a neighbour's centre, which is what
   the hit-area contract actually states. The current-page crumb is a <span>,
   not a control, so it is deliberately left out. */
a.ds-breadcrumb__link {
  margin: -11px -5px;
  padding: 11px 5px;
}

.ds-breadcrumb__link:hover {
  color: var(--link-hover);
}

.ds-breadcrumb__link--current {
  color: var(--fg);
  font-weight: var(--fw-medium);
}

.ds-breadcrumb__separator {
  /* The separator is the only visual cue for the trail's structure, so it is
     a meaningful graphic, not decoration. --fg-4 was 2.67:1. */
  color: var(--fg-3); /* 4.85:1 light, 7.87:1 dark */
  font-size: var(--fs-sm);
}

/* ==========================================================================
   Card
   ========================================================================== */

.ds-card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-sm);
}

.ds-card__header {
  display: grid;
  gap: var(--space-1);
  padding: var(--space-6) var(--space-6) 0;
}

.ds-card__title {
  color: var(--fg);
  font-family: var(--font-sans);
  font-size: var(--fs-lg);
  font-weight: var(--fw-semibold);
  letter-spacing: var(--tr-tight);
  line-height: var(--lh-tight);
  margin: 0;
}

.ds-card__description {
  color: var(--fg-2);
  font-family: var(--font-sans);
  font-size: var(--fs-sm);
  line-height: var(--lh-normal);
  margin: 0;
}

.ds-card__content {
  padding: var(--space-6);
}

.ds-card__footer {
  align-items: center;
  display: flex;
  gap: var(--space-3);
  padding: 0 var(--space-6) var(--space-6);
}

/* ==========================================================================
   Chip
   ========================================================================== */

.ds-chip {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  padding: 0 var(--space-3);
  min-height: 32px;
  border-radius: var(--radius-pill);
  background: var(--bg-subtle);
  border: 1px solid var(--border-control); /* control edge — SC 1.4.11 */
  /* A <span class="ds-chip"> inherits the family and this changes nothing for
     it. <button class="ds-chip"> is the reason it is here: a button does not
     inherit font-family — the UA stylesheet sets it — so the button form of a
     chip painted its label in Arial while the span form painted it in Geist,
     at the same declared font-size. A chip's label is text, so this is the
     right fix for it; the remove button's glyph is not text any more and is
     handled differently — see lib/marks.tsx. */
  font-family: var(--font-sans);
  font-size: var(--fs-xs);
  color: var(--fg);
}

/* 22px painted, on purpose: MUI's Chip delete icon is 22px and that is the
   scale a chip's remove glyph belongs to. The hit area is a different question
   from the paint, and it was the same 22x22 until #116 — 2px under
   --target-min in both axes, the one thing the system shipped below the WCAG
   2.2 AA floor (SC 2.5.8). ::after below closes that without touching the
   glyph. */
.ds-chip__remove {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 22px;
  height: 22px;
  margin-right: -4px;
  border-radius: 9999px;
  border: none;
  background: transparent;
  color: var(--fg-3);
  cursor: pointer;
  /* The glyph is drawn, not typed. It used to be a literal `×` text node, and
     that made this control's rendering depend on a font — on a <button>, a font
     nobody declared, since font-family is not inherited and the UA stylesheet
     sets it. The × came out in the UA default beside a Geist label.

     Declaring the family fixed the typeface and not the geometry. Measured on
     <Chip onRemove>Design</Chip>, as painted ink: `align-items: center` centres
     the line *box*, the baseline sits (ascent - descent) / 2 below that box's
     centre — 5.00px, the same for both families — and where the ink lands then
     depends on where the family puts it above its own baseline. Arial 4.95px,
     Geist 4.13px, so the same declaration centred one to 0.05px and the other
     to 0.87px. Neither was designed. And --font-sans is a token a consumer may
     re-point, which would move it again by some third amount.

     lib/marks.tsx draws it instead: a replaced element, symmetric about its own
     centre, blockified as a flex item and centred by the flex box already
     declared above. No baseline, no metrics, nothing to inherit — the offset is
     zero by construction rather than small by luck.

     Verified as painted pixels at deviceScaleFactor 8, so a CSS pixel resolves
     to an eighth of one, comparing the mark's stroke ink against the chip's own
     painted box rather than against a layout rectangle:

       text `×`, UA default   ink 6.13px   centre +0.063px
       text `×`, Geist        ink 6.00px   centre +0.875px
       drawn mark             ink 6.75px   centre  0.000px

     and 0.000px at every tenth of a pixel through a full pixel of layout
     nudging, which is the part that says "by construction". Chromium snaps an
     element's raster to whole CSS pixels, so the mark's box can sit up to 0.5px
     from where layout puts it — but the chip snaps with it, so what a reader
     sees stays exactly centred. The old text glyph was subject to that same
     snapping *plus* the metric offset above.

     The label's own ink centre sits 0.31px above the chip's centre. That is the
     word — cap heights and a descender — not this control, it was the same
     before, and it is not something to chase.

     --mark-size is read from the type ramp directly rather than as `1em`, so
     the mark keeps its size even if a consumer restyles this button's text.
     font-family and font-size stay for the hand-written `.ds-chip__remove` that
     carries a text node of its own: nothing <Chip> renders needs them, and if
     something ever does, it should be Geist at the ramp's step rather than the
     UA default all over again. */
  --mark-size: var(--fs-sm);
  font-family: var(--font-sans);
  font-size: var(--fs-sm);
  line-height: 1;
}

/* The hit area, reaching --target-min without growing the painted circle.
   Sized, not inset: `inset: -1px` would be the symmetric negative inset that
   hit-area-no-overlap exists to warn about, and it would also grow with the
   button — under data-platform="mobile" the remove button is floored to 44px
   tall, and a symmetric inset would then hang 1px past a chip it has already
   stretched. A fixed --target-min box centred on the control cannot: it is
   24x24 in every condition, which is exactly the floor and nothing more.

   Overlap headroom, measured on <Chip onRemove>Design</Chip>: the label's
   centre is 34.07px from this control's centre and this overlay reaches 12px,
   so it clears the label by 22px. Both halves — the 24x24 reach and the
   absence of an overlap — are asserted against the shipped stylesheet in
   packages/ai-patterns/src/testing/playwright.test.mjs.

   Centred with negative margins rather than `transform: translate(-50%,-50%)`,
   which is the usual way to write this and was how it was written first. A
   transform on an element creates a containing block and hands the subtree to
   the compositor, and Chromium then drops the × from subpixel (LCD) to
   grayscale antialiasing — the overlay is transparent, but the glyph it sits
   on top of is composited differently because of it. That is a real, visible
   change in how the text is painted, and it is not noise: it moved 184 pixels
   in four 8x8 clusters, one centred on each × in the chip row, with the
   expected pixels carrying the colour fringes of subpixel AA (rgb(0,10,36),
   rgb(116,75,36)) and the actual ones the neutral greys of grayscale
   (rgb(0,1,1), rgb(67,68,70)).

   The margins are `--target-min / -2`, so they track the token the box is
   sized from and cannot drift from it. No transform, no stacking context, and
   the glyph is painted exactly as it was before this overlay existed —
   verified byte-for-byte against main's baseline rather than assumed. */
.ds-chip__remove::after {
  content: '';
  position: absolute;
  top: 50%;
  left: 50%;
  width: var(--target-min);
  height: var(--target-min);
  margin-top: calc(var(--target-min) / -2);
  margin-left: calc(var(--target-min) / -2);
}

.ds-chip__remove:hover {
  background: var(--border);
  color: var(--fg);
}

.ds-chip__remove:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 1px;
}

/* ==========================================================================
   DecisionCard
   ========================================================================== */

.ds-decision {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-sm);
  display: grid;
  gap: var(--space-5);
  padding: var(--space-6);
}

.ds-decision__head {
  align-items: center;
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-3);
  justify-content: space-between;
}

.ds-decision__subject {
  color: var(--fg-3);
  font-family: var(--font-sans);
  font-size: var(--fs-2xs);
  letter-spacing: var(--tr-wide);
  line-height: 1;
  margin: 0;
  text-transform: uppercase;
}

.ds-decision__body {
  display: grid;
  gap: var(--space-4);
}

.ds-decision__headline {
  color: var(--fg);
  font-family: var(--font-display);
  font-size: var(--fs-2xl);
  font-weight: var(--fw-semibold);
  letter-spacing: var(--tr-tight);
  line-height: var(--lh-snug);
  margin: 0;
}

.ds-decision__figures {
  display: grid;
  gap: var(--space-2);
  margin: 0;
}

.ds-decision__figure {
  align-items: baseline;
  display: flex;
  gap: var(--space-3);
  justify-content: space-between;
}

.ds-decision__figure-label {
  color: var(--fg-2);
  font-family: var(--font-sans);
  font-size: var(--fs-sm);
  line-height: var(--lh-normal);
}

.ds-decision__figure-value {
  color: var(--fg);
  font-family: var(--font-mono);
  font-size: var(--fs-sm);
  line-height: var(--lh-normal);
  margin: 0;
  text-align: right;
}

.ds-decision__total {
  align-items: baseline;
  border-top: 1px solid var(--border);
  display: flex;
  font-size: var(--fs-md);
  font-weight: var(--fw-semibold);
  gap: var(--space-3);
  justify-content: space-between;
  margin: 0;
  padding-top: var(--space-3);
}

.ds-decision__total .ds-decision__figure-label,
.ds-decision__total .ds-decision__figure-value {
  color: var(--fg);
  font-size: var(--fs-md);
  font-weight: var(--fw-semibold);
}

/* The comparison figure is secondary by construction: it exists to give the
   total a size, so it must never compete with it. */
.ds-decision__contrast {
  align-items: baseline;
  color: var(--fg-3);
  display: flex;
  font-family: var(--font-sans);
  font-size: var(--fs-xs);
  gap: var(--space-3);
  justify-content: space-between;
  line-height: var(--lh-normal);
  margin: 0;
}

.ds-decision__contrast .ds-decision__figure-label,
.ds-decision__contrast .ds-decision__figure-value {
  color: var(--fg-3);
  font-size: var(--fs-xs);
}

/* --status-warning-border, not --status-warning, and not --signal-500.
 *
 * Three tokens, three different mistakes. --signal-500 is a base-scale value
 * that does not move with the theme OR the palette — it is amber under ember
 * and teal under slate, and a teal caution rule is not a caution.
 *
 * --status-warning is the right family and the wrong member. This rule is the
 * only thing marking the paragraph as a caution, which makes it a meaningful
 * non-text graphic under SC 1.4.11 and puts a 3:1 floor on it — and the
 * warning FILL is 1.87:1 on a light page, because a yellow pushed dark enough
 * to clear 3:1 on white is olive and stops reading as caution. That is the
 * documented exception in the token set, and --status-warning-border is the
 * token that exists to carry the floor the fill cannot: 3.76:1 light, 11.17:1
 * dark. On ink no lift is needed, so there the border token IS the fill.
 *
 * The caveat's text takes --status-warning-fg (6.97:1 light, 11.86:1 dark)
 * rather than --fg-2. --fg-2 is the higher ratio of the two at 8.45:1, so this
 * is not a contrast decision: it is SC 1.4.1. A rule alone puts the whole
 * caution on one thin line of colour at the paragraph's edge, and a reader who
 * misses it reads the caveat as body copy. Tinting the words is the second
 * channel. Both members go through --product-caveat*, so a product re-hues the
 * rule and the text together — the pair that must never move apart. */
.ds-decision__caveat {
  border-left: 3px solid var(--product-caveat-rule, var(--status-warning-border));
  color: var(--product-caveat-fg, var(--status-warning-fg));
  font-family: var(--font-sans);
  font-size: var(--fs-sm);
  line-height: var(--lh-normal);
  margin: 0;
  padding-left: var(--space-3);
}

.ds-decision__closing {
  color: var(--fg-2);
  font-family: var(--font-sans);
  font-size: var(--fs-sm);
  line-height: var(--lh-normal);
  margin: 0;
}

/* The action row exists only when there is an action — see the comment in
   DecisionCard.tsx. There is deliberately no disabled or empty presentation
   for this element, because it is never rendered empty. */
.ds-decision__foot {
  align-items: center;
  border-top: 1px solid var(--border);
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-3);
  padding-top: var(--space-4);
}

/* ==========================================================================
   EmptyState
   ========================================================================== */

.ds-empty-state {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  gap: var(--space-2);
  padding: var(--space-8);
  color: var(--fg-3);
}

.ds-empty-state__icon {
  font-size: var(--fs-lg);
  /* The icon carries the state, so it is a meaningful graphic. --fg-4 was
     2.67:1 against a light page. */
  color: var(--fg-3); /* 4.85:1 light, 7.87:1 dark */
}

.ds-empty-state__title {
  margin: 0;
  font-size: var(--fs-md);
  font-weight: var(--fw-medium);
  color: var(--fg);
}

.ds-empty-state__description {
  margin: 0;
  font-size: var(--fs-sm);
}

.ds-empty-state__action {
  margin-top: var(--space-2);
}

/* ==========================================================================
   FormField
   ========================================================================== */

.ds-form-field {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
}

.ds-form-field__label {
  margin-bottom: 2px;
}

.ds-form-field__required {
  color: var(--status-danger-fg); /* 7.55:1 — the fill token is 4.79:1 */
}

.ds-form-field__message {
  font-size: var(--fs-xs);
  margin: 0;
}

.ds-form-field__message--hint {
  color: var(--fg-3);
}

.ds-form-field__message--error {
  color: var(--status-danger-fg); /* 7.55:1 — the fill token is 4.79:1 */
}

/* ==========================================================================
   Pagination
   ========================================================================== */

.ds-pagination {
  display: flex;
  align-items: center;
  gap: var(--space-2);
}

.ds-pagination__list {
  display: flex;
  gap: var(--space-1);
  list-style: none;
  margin: 0;
  padding: 0;
}

.ds-pagination__nav,
.ds-pagination__item {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 44px;
  min-height: 44px;
  border-radius: var(--radius-sm);
  border: 1px solid var(--border-control); /* control edge — SC 1.4.11 */
  background: var(--surface);
  color: var(--fg);
  cursor: pointer;
  font-size: var(--fs-sm);
}

/* Only the nav buttons paint a mark; .ds-pagination__item above shares their
   box but carries a real page number, so its type stays its own concern (#167).
   --mark-size from the same ramp step the shared rule sets font-size to, so the
   chevron is the size the character it replaces was asked to be. */
.ds-pagination__nav {
  --mark-size: var(--fs-sm);
}

.ds-pagination__nav:disabled {
  opacity: 0.4;
  cursor: not-allowed;
}

.ds-pagination__item--active {
  background: var(--accent);
  color: var(--accent-fg);
  border-color: var(--accent);
}

.ds-pagination__nav:focus-visible,
.ds-pagination__item:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 1px;
}

/* ==========================================================================
   Rating
   ========================================================================== */

.ds-rating {
  display: inline-flex;
  gap: 2px;
}

.ds-rating__star {
  /* inline-flex so the star is centred by a flex box rather than seated on a
     text baseline — see the same note on .ds-date-picker__header button. */
  display: inline-flex;
  align-items: center;
  justify-content: center;
  /* An empty star still has to be countable — it carries the rating as much
     as a filled one does. 3.64:1 under SC 1.4.11, not the 1.53:1 hairline. */
  color: var(--border-control);
  --mark-size: var(--fs-lg);
  font-size: var(--fs-lg);
}

.ds-rating__star--filled {
  /* --accent is 2.53:1 against --bg and --signal-800 is 2.17:1 against a dark
     one. A filled star is a mark a user reads, so it takes --accent-ink. */
  color: var(--accent-ink);
}

.ds-rating__button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 32px;
  border: none;
  background: transparent;
  /* An empty star still has to be countable — it carries the rating as much
     as a filled one does. 3.64:1 under SC 1.4.11, not the 1.53:1 hairline. */
  color: var(--border-control);
  --mark-size: var(--fs-lg);
  font-size: var(--fs-lg);
  cursor: pointer;
}

.ds-rating__button.ds-rating__star--filled {
  /* --accent is 2.53:1 against --bg and --signal-800 is 2.17:1 against a dark
     one. A filled star is a mark a user reads, so it takes --accent-ink. */
  color: var(--accent-ink);
}

.ds-rating__button:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 1px;
}

/* ==========================================================================
   Rule Link
   ========================================================================== */

.ds-rule-link {
  align-items: center;
  /* The rule under the label is the component's whole identity — fixed at
     --ink-1000 it was a black line on a black page. */
  border-bottom: 1px solid var(--fg);
  color: var(--fg);
  display: inline-flex;
  font-family: var(--font-sans);
  font-size: var(--fs-sm);
  font-weight: var(--fw-medium);
  gap: var(--space-2);
  /* A standalone call-to-action link, so the 44px rule applies. 18px of hit
     area above the 24px line box and the 2px holding the rule off it comes to
     44.1, and an equal negative margin takes it straight back off the line box,
     so nothing moves. Width already clears 44.

     Upward only, deliberately: padding at the bottom would push the border down,
     and that border is the component's whole identity.

     A positioned ::after overlay was tried first and reverted. It needs
     `position: relative` here, which promotes the link into the positioned paint
     phase, at which point Chromium drops LCD subpixel antialiasing for text it
     can no longer prove sits on an opaque background — the label rendered
     neutral grey instead of colour-fringed, i.e. visibly softer, without a
     single pixel of layout moving. Padding does not trigger that. */
  margin-top: -18px;
  padding-bottom: 2px;
  padding-top: 18px;
  text-decoration: none;
}

.ds-rule-link:hover {
  color: var(--link-hover);
}

.ds-rule-link:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 2px;
}

.ds-radio-group {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
}

.ds-radio-group__item {
  display: flex;
  align-items: center;
  /* The row is the label now, so the whole of it is clickable. */
  cursor: pointer;
  gap: var(--space-2);
  min-height: 44px;
}

.ds-radio-group__input {
  width: 20px;
  height: 20px;
  accent-color: var(--accent);
}

.ds-radio-group__input:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 2px;
}

.ds-radio-group__label {
  font-size: var(--fs-sm);
  color: var(--fg);
}

/* ==========================================================================
   SearchField
   ========================================================================== */

.ds-search-field {
  position: relative;
  display: flex;
  align-items: center;
}

.ds-search-field__icon {
  position: absolute;
  left: var(--space-3);
  color: var(--fg-3);
  pointer-events: none;
}

.ds-search-field__input {
  width: 100%;
  min-height: 44px;
  padding: 0 var(--space-8) 0 var(--space-8);
  border-radius: var(--radius-md);
  border: 1px solid var(--border-control); /* control edge — SC 1.4.11 */
  background: var(--surface);
  color: var(--fg);
  font-size: var(--fs-sm);
}

.ds-search-field__input:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 1px;
}

.ds-search-field__input::-webkit-search-cancel-button {
  -webkit-appearance: none;
  appearance: none;
}

.ds-search-field__clear {
  position: absolute;
  right: var(--space-2);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 24px;
  height: 24px;
  border-radius: 9999px;
  border: none;
  background: transparent;
  color: var(--fg-3);
  cursor: pointer;
  /* --mark-size is read from the type ramp directly rather than as `1em`, so
     the mark keeps its size even if a consumer restyles this control's text.

     No font-family here, deliberately. .ds-chip__remove declares one because it
     predates #170, which puts a single layered `font-family: inherit` on native
     form controls in tokens.css — restating it per control would be five more
     copies of a rule being centralised next door, and unlayered ones that would
     win against it. This control paints a mark and no text, so it has nothing
     of its own to typeset either way. */
  --mark-size: var(--fs-md);
  font-size: var(--fs-md);
}

.ds-search-field__clear:hover {
  background: var(--bg-subtle);
  color: var(--fg);
}

.ds-search-field__clear:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 1px;
}

/* ==========================================================================
   SegmentedControl
   ========================================================================== */

.ds-segmented-control {
  display: inline-flex;
  padding: 2px;
  border-radius: var(--radius-md);
  background: var(--bg-subtle);
  border: 1px solid var(--border-control); /* control edge — SC 1.4.11 */
}

.ds-segmented-control__item {
  min-width: 44px;
  min-height: 44px;
  padding: 0 var(--space-3);
  border: none;
  border-radius: var(--radius-sm);
  background: transparent;
  color: var(--fg-2);
  font-size: var(--fs-sm);
  cursor: pointer;
}

.ds-segmented-control__item--active {
  background: var(--surface);
  color: var(--fg);
  box-shadow: var(--shadow-sm);
}

.ds-segmented-control__item:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 1px;
}

/* ==========================================================================
   Stepper
   ========================================================================== */

.ds-stepper {
  display: flex;
  list-style: none;
  margin: 0;
  padding: 0;
}

.ds-stepper__step {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  flex: 1;
  font-size: var(--fs-sm);
  color: var(--fg-3);
}

.ds-stepper__step:not(:last-child)::after {
  content: '';
  flex: 1;
  height: 1px;
  background: var(--border);
  margin: 0 var(--space-2);
}

.ds-stepper__indicator {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 24px;
  height: 24px;
  border-radius: 9999px;
  /* The ring is the step marker — an upcoming step is nothing but its ring.
     3.64:1 under SC 1.4.11, not the 1.53:1 hairline. */
  border: 1px solid var(--border-control);
  font-size: var(--fs-xs);
  flex-shrink: 0;
}

.ds-stepper__step--active .ds-stepper__indicator {
  border-color: var(--accent);
  background: var(--accent);
  color: var(--accent-fg);
}

.ds-stepper__step--active .ds-stepper__label {
  color: var(--fg);
  font-weight: var(--fw-medium);
}

/* The label is --status-success-on, the text that belongs to this fill, and
   not --surface. --surface is a themed page colour: on a dark page it resolves
   near-black, so a completed step drew a dark tick on a mid green. The -on
   member is the same in both themes because the fill is. */
.ds-stepper__step--complete .ds-stepper__indicator {
  border-color: var(--status-success);
  background: var(--status-success);
  color: var(--status-success-on); /* 5.17:1 on the fill, both themes */
}

/* ==========================================================================
   StubCard
   ========================================================================== */

.ds-stub {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  display: grid;
  grid-template-columns: 1fr auto;
  overflow: hidden;
}

.ds-stub__body {
  display: grid;
  gap: var(--space-3);
  padding: var(--space-5) var(--space-6);
}

.ds-stub__title {
  color: var(--fg);
  font-family: var(--font-sans);
  font-size: var(--fs-md);
  font-weight: var(--fw-semibold);
  letter-spacing: var(--tr-snug);
  line-height: var(--lh-snug);
  margin: 0;
}

.ds-stub__items {
  display: grid;
  gap: var(--space-2);
  margin: 0;
}

.ds-stub__item {
  align-items: baseline;
  display: flex;
  gap: var(--space-3);
  justify-content: space-between;
}

.ds-stub__label {
  color: var(--fg-3);
  font-family: var(--font-sans);
  font-size: var(--fs-sm);
  line-height: var(--lh-normal);
}

.ds-stub__value {
  color: var(--fg);
  font-family: var(--font-sans);
  font-size: var(--fs-sm);
  font-weight: var(--fw-medium);
  line-height: var(--lh-normal);
  margin: 0;
  text-align: right;
}

.ds-stub__footnote {
  color: var(--fg-3);
  font-family: var(--font-sans);
  font-size: var(--fs-xs);
  line-height: var(--lh-normal);
  margin: 0;
}

/* The perforation. It is drawn with --border-control rather than --border
   because it is not decoration between two blocks of the same surface: it is
   the tear line that says the stub detaches, which is the whole reason the
   layout has two columns. --border is a 1.24:1 seam meant to be felt and not
   read; a line carrying meaning needs the 3:1 edge token, the same one a
   control's boundary uses. Dashed, because a perforation is dashed.

   Neither automated check would have caught the wrong token here, which is why
   this comment exists rather than a lint suppression. component-css.test.mjs's
   CONTROL_SELECTOR and @elirobinson-css/no-decorative-control-edge's EDGE_WORDS
   both read the class name, and "stub" is not a control word in either — as it
   should not be, since this is not a control. The 3:1 requirement follows from
   the line carrying meaning, not from the element being interactive, and that
   is a judgement no name-matching rule can make. Verified against the rule:
   .ds-stub__stub does not match it, so this token is a choice, not a fix. */
.ds-stub__stub {
  align-content: center;
  border-left: 1px dashed var(--border-control);
  display: grid;
  gap: var(--space-1);
  justify-items: center;
  padding: var(--space-5) var(--space-6);
  text-align: center;
}

.ds-stub__stub-label {
  color: var(--fg-3);
  font-family: var(--font-sans);
  font-size: var(--fs-2xs);
  letter-spacing: var(--tr-caps);
  line-height: 1;
  margin: 0;
  text-transform: uppercase;
}

.ds-stub__stub-value {
  color: var(--fg);
  font-family: var(--font-sans);
  font-size: var(--fs-xl);
  font-weight: var(--fw-semibold);
  letter-spacing: var(--tr-tight);
  line-height: var(--lh-snug);
  margin: 0;
}

.ds-stub__stub-caption {
  color: var(--fg-3);
  font-family: var(--font-sans);
  font-size: var(--fs-xs);
  line-height: var(--lh-normal);
  margin: 0;
}

/* Narrow: the stub tears off the bottom instead of the side, so the
   perforation moves with it and stays a perforation. */
@media (max-width: 30rem) {
  .ds-stub {
    grid-template-columns: 1fr;
  }

  .ds-stub__stub {
    border-left: 0;
    border-top: 1px dashed var(--border-control);
  }
}

/* ==========================================================================
   VerdictBadge
   ========================================================================== */

.ds-verdict {
  align-items: center;
  border-radius: var(--radius-pill);
  display: inline-flex;
  font-family: var(--font-sans);
  font-size: var(--fs-2xs);
  font-weight: var(--fw-semibold);
  gap: var(--space-2);
  letter-spacing: var(--tr-caps);
  line-height: 1;
  padding: var(--space-2) var(--space-3);
  text-transform: uppercase;
}

.ds-verdict__glyph {
  font-size: var(--fs-sm);
  line-height: 1;
}

.ds-verdict__word {
  line-height: 1;
}

/* Each verdict is a themed status pair, read through the product layer with a
   one-level fallback so the layer stays optional.
 *
 * The draft paired --anchor-100 with --anchor-800 and needed a
 * [data-theme='dark'] block to stop light mint sitting behind light green.
 * Both are base-scale tokens, which this system forbids painting: a scale does
 * not respond to the theme attribute, so painting one asserts the colour is
 * right on a white page and a black one. --status-success-tint IS that pair,
 * already themed, and against --status-success-fg it measures 6.71:1 light /
 * 9.42:1 dark. So the fix lands with no dark block, no exemption, and the
 * product layer still overrides fill and text together.
 *
 * All three pairs below are palette-independent. Status owns its own four
 * hues and no [data-palette] block touches them, so a verdict badge is the
 * same green, red and yellow whichever brand is mounted — which is the deeper
 * reason a verdict is not painted in --anchor or --accent, beyond the
 * base-scale rule. Every ratio here is identical in ember and slate. */
.ds-verdict--go {
  background: var(--product-verdict-go, var(--status-success-tint));
  color: var(--product-verdict-go-fg, var(--status-success-fg)); /* 6.71:1 light, 9.42:1 dark */
}

/* It paints a background, so it states its own color in the same rule — a
   fill without a colour lets an outer rule repaint the label. */
.ds-verdict--no {
  background: var(--product-verdict-no, var(--status-danger-tint));
  color: var(--product-verdict-no-fg, var(--status-danger-fg)); /* 6.69:1 light, 6.63:1 dark */
}

.ds-verdict--hold {
  background: var(--product-verdict-hold, var(--status-warning-tint));
  color: var(--product-verdict-hold-fg, var(--status-warning-fg)); /* 6.24:1 light, 9.85:1 dark */
}

/* ==========================================================================
   Accordion
   ========================================================================== */

.ds-accordion {
  border-top: 1px solid var(--border);
}

.ds-accordion__item {
  border-bottom: 1px solid var(--border);
}

.ds-accordion__heading {
  margin: 0;
}

.ds-accordion__trigger {
  display: flex;
  align-items: center;
  justify-content: space-between;
  width: 100%;
  min-height: 44px;
  padding: var(--space-3) 0;
  border: none;
  background: transparent;
  color: var(--fg);
  font-size: var(--fs-sm);
  font-weight: var(--fw-medium);
  text-align: left;
  cursor: pointer;
  /* --mark-size matches the ramp step this trigger sets, so the mark is the
     size the character it replaces was asking to be. */
  --mark-size: var(--fs-sm);
}

/* The mark's colour, and nothing else. It used to be a ::after carrying
   `content: '+'` / `'\2212'`; #167's follow-up made it a drawn <Mark>, so the
   glyph and the open/closed swap both moved into the component and only the
   colour is left here.

   No selector for the open state: the trigger renders `minus` instead of
   `plus`, so which mark is painted is a fact about the component rather than a
   second CSS rule that has to be kept in step with it. */
.ds-accordion__trigger .ds-mark {
  color: var(--fg-3);
}

.ds-accordion__trigger:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 2px;
}

.ds-accordion__content {
  padding-bottom: var(--space-3);
  color: var(--fg-2);
  font-size: var(--fs-sm);
}

/* ==========================================================================
   Combobox
   ========================================================================== */

.ds-combobox {
  position: relative;
  width: 100%;
}

.ds-combobox__list {
  position: absolute;
  z-index: var(--z-overlay);
  margin: var(--space-1) 0 0;
  padding: var(--space-1) 0;
  width: 100%;
  overflow-y: auto;
  border-radius: var(--radius-md);
  border: 1px solid var(--border);
  background: var(--surface);
  box-shadow: var(--shadow-lg);
}

.ds-combobox__option {
  display: flex;
  align-items: center;
  width: 100%;
  min-height: 44px;
  padding: 0 var(--space-3);
  color: var(--fg);
  font-size: var(--fs-sm);
  cursor: pointer;
}

.ds-combobox__option--active {
  background: var(--bg-subtle);
}

.ds-combobox__option--selected {
  font-weight: var(--fw-medium);
}

.ds-combobox__empty {
  padding: var(--space-3);
  color: var(--fg-3);
  font-size: var(--fs-sm);
}

/* ==========================================================================
   CommandPalette
   ========================================================================== */

/* No custom width/backdrop/stacking rules here -- CommandPalette renders its
   body inside <DialogContent>, so `.ds-dialog` (max-width, backdrop) and
   `.ds-dialog__inner` (surface, border, shadow, grid layout, gap, padding)
   already own those concerns. This block only styles what's unique to the
   palette's own content: the search field spacing, the command list, and
   its items. */

.ds-command-palette .ds-search-field {
  width: 100%;
}

.ds-command-palette__list {
  list-style: none;
  margin: 0;
  padding: 0;
  max-height: 320px;
  overflow-y: auto;
}

.ds-command-palette__item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  width: 100%;
  min-height: 44px;
  padding: 0 var(--space-3);
  border-radius: var(--radius-sm);
  color: var(--fg);
  font-size: var(--fs-sm);
  cursor: pointer;
}

.ds-command-palette__item:hover,
.ds-command-palette__item--active {
  background: var(--bg-subtle);
  color: var(--fg); /* 20.06:1 */
}

.ds-command-palette__shortcut {
  display: flex;
  gap: 4px;
}

.ds-command-palette__empty {
  padding: var(--space-3);
  color: var(--fg-3);
  font-size: var(--fs-sm);
}

/* ==========================================================================
   DatePicker
   ========================================================================== */

.ds-date-picker {
  position: relative;
  display: inline-block;
}

/* `top`/`left` are pinned rather than left to the static position, which is
   only "just below the input" while `.ds-date-picker` is the inline-block this
   sheet ships. Give the wrapper a `display` of `flex` or `grid` -- a consumer
   class constraining its width is enough, and the docs site's `.demo-col` did
   exactly that -- and an absolutely positioned child's static position becomes
   the container's content-box corner instead, dropping the whole calendar on
   top of the field it belongs under. Anchoring to the wrapper's bottom edge
   renders identically under the shipped `display` and survives any other. */
.ds-date-picker__popover {
  position: absolute;
  top: 100%;
  left: 0;
  z-index: var(--z-overlay);
  margin-top: var(--space-1);
  padding: var(--space-3);
  border-radius: var(--radius-md);
  border: 1px solid var(--border);
  background: var(--surface);
  box-shadow: var(--shadow-lg);
  width: 280px;
}

.ds-date-picker__header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: var(--space-2);
  font-size: var(--fs-sm);
  font-weight: var(--fw-medium);
}

.ds-date-picker__header button {
  /* inline-flex, which this did not declare before it painted a mark. A mark
     centres because the flex box it sits in centres it; on a native button
     with no display of its own it would be an inline replaced element sitting
     on the text baseline, which is the font-metric positioning the drawn mark
     exists to get away from. */
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: none;
  background: transparent;
  cursor: pointer;
  /* --mark-size is read from the type ramp directly rather than as `1em`, so
     the mark keeps its size even if a consumer restyles this control's text.

     No font-family here, deliberately. .ds-chip__remove declares one because it
     predates #170, which puts a single layered `font-family: inherit` on native
     form controls in tokens.css — restating it per control would be five more
     copies of a rule being centralised next door, and unlayered ones that would
     win against it. This control paints a mark and no text, so it has nothing
     of its own to typeset either way. */
  --mark-size: var(--fs-md);
  font-size: var(--fs-md);
  color: var(--fg-2);
}

.ds-date-picker__grid {
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.ds-date-picker__row {
  display: grid;
  grid-template-columns: repeat(7, 1fr);
  gap: 2px;
}

/* The weekday header shares the week rows' 7-column track above, so the
   labels stay in the same grid columns as the days they name -- a separate
   layout here could drift out of alignment with them. */
.ds-date-picker__row--head {
  margin-bottom: var(--space-1);
}

.ds-date-picker__weekday {
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: var(--fs-xs);
  font-weight: var(--fw-medium);
  color: var(--fg-2);
}

.ds-date-picker__cell {
  display: flex;
  align-items: center;
  justify-content: center;
}

.ds-date-picker__day {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 32px;
  border: none;
  border-radius: var(--radius-sm);
  background: transparent;
  color: var(--fg);
  font-size: var(--fs-xs);
  cursor: pointer;
}

.ds-date-picker__day:hover {
  background: var(--bg-subtle);
  color: var(--fg); /* 20.06:1 */
}

.ds-date-picker__day:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 1px;
}

.ds-date-picker__day--today {
  font-weight: var(--fw-medium);
  box-shadow: inset 0 0 0 1px var(--border);
}

.ds-date-picker__day--selected {
  background: var(--accent);
  color: var(--accent-fg);
}

/* The same shape as the reported Button bug: an accent fill on hover with no
   colour of its own, so anything outranking the variant repaints the label. */
.ds-date-picker__day--selected:hover {
  background: var(--accent-hover);
  color: var(--accent-fg); /* 10.17:1 */
}

.ds-date-picker__day--today.ds-date-picker__day--selected {
  box-shadow: none;
}

/* ==========================================================================
   Dialog
   ========================================================================== */

.ds-dialog {
  background: transparent;
  border: none;
  /* Centring is declared here rather than inherited from the UA stylesheet's
     `dialog { margin: auto }`. Any consumer reset with a universal
     `margin: 0` -- Tailwind preflight, normalize, sanitize.css, most
     hand-rolled ones -- is an *author* rule and so beats the UA at any
     specificity; with the margin gone, `position: absolute; inset: 0` on a
     box narrower than the viewport pins the dialog to the top-left corner.
     Restating it as an author rule of our own is enough, since `.ds-dialog`
     outranks `*`. Same posture Sheet.css already takes. */
  margin: auto;
  max-height: 90vh;
  max-width: 480px;
  padding: 0;
  width: calc(100% - var(--space-8));
}

.ds-dialog::backdrop {
  background: oklch(0% 0 0 / 0.5);
}

.ds-dialog__inner {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-lg);
  display: grid;
  gap: var(--space-4);
  padding: var(--space-6);
}

.ds-dialog__header {
  display: grid;
  gap: var(--space-1);
}

.ds-dialog__title {
  color: var(--fg);
  font-family: var(--font-sans);
  font-size: var(--fs-lg);
  font-weight: var(--fw-semibold);
  letter-spacing: var(--tr-tight);
  margin: 0;
}

.ds-dialog__description {
  color: var(--fg-2);
  font-family: var(--font-sans);
  font-size: var(--fs-sm);
  line-height: var(--lh-normal);
  margin: 0;
}

.ds-dialog__footer {
  display: flex;
  gap: var(--space-3);
  justify-content: flex-end;
}

/* ==========================================================================
   Dropdown Menu
   ========================================================================== */

/* The trigger is a control and takes --border-control; the floating panel
   below is a surface edge over a shadow and stays decorative. */
.ds-dropdown__trigger {
  align-items: center;
  background: transparent;
  border: 1px solid var(--border-control);
  border-radius: var(--radius-sm);
  color: var(--fg);
  cursor: pointer;
  display: inline-flex;
  font-family: var(--font-sans);
  font-size: var(--fs-sm);
  gap: var(--space-2);
  justify-content: center;
  min-height: 44px;
  padding: var(--space-2) var(--space-4);
}

.ds-dropdown__trigger:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 2px;
}

/* --anchored-min-width is this panel's floor, and useAnchoredPosition reads it
   back: the positioner writes an inline `min-width` to keep the menu at least
   as wide as its trigger, which outranks anything here, so the number has to
   reach it rather than be restated in JS. See the hook for the full story. */
.ds-dropdown__content {
  --anchored-min-width: 180px;

  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-md);
  display: grid;
  gap: var(--space-1);
  min-width: var(--anchored-min-width);
  padding: var(--space-2);
}

.ds-dropdown__item {
  align-items: center;
  background: transparent;
  border: none;
  border-radius: var(--radius-sm);
  color: var(--fg);
  cursor: pointer;
  display: flex;
  font-family: var(--font-sans);
  font-size: var(--fs-sm);
  gap: var(--space-2);
  min-height: 44px;
  padding: var(--space-2) var(--space-3);
  text-align: left;
  width: 100%;
}

/* A menu item is often an <a>. Restating the colour keeps the global
   `a:hover` hue shift off it — see docs/agents/components.md. */
.ds-dropdown__item:hover,
.ds-dropdown__item:focus-visible {
  background: var(--bg-subtle);
  color: var(--fg); /* 20.06:1 */
  outline: none;
}

.ds-dropdown__item:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: -2px;
}

.ds-dropdown__item--inset {
  padding-left: var(--space-6);
}

.ds-dropdown__label {
  color: var(--fg-3);
  font-family: var(--font-mono);
  font-size: var(--fs-2xs);
  font-weight: var(--fw-medium);
  letter-spacing: var(--tr-caps);
  padding: var(--space-2) var(--space-3);
  text-transform: uppercase;
}

.ds-dropdown__label--inset {
  padding-left: var(--space-6);
}

.ds-dropdown__separator {
  background: var(--border);
  height: 1px;
  margin: var(--space-1) 0;
}

/* ==========================================================================
   NavigationMenu
   ========================================================================== */

.ds-navigation-menu__list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
}

.ds-navigation-menu__list .ds-navigation-menu__list {
  padding-left: var(--space-4);
}

/* An item with no href: a heading naming the nested list under it, not a
   navigation target. No hover, active, or focus state -- nothing to click, and
   no 44px touch target to hit. */
.ds-navigation-menu__label {
  display: flex;
  align-items: center;
  padding: var(--space-2) var(--space-3);
  color: var(--fg-3);
  font-size: var(--fs-xs);
  font-weight: var(--fw-medium);
}

.ds-navigation-menu__link {
  display: flex;
  align-items: center;
  min-height: 44px;
  padding: 0 var(--space-3);
  border-radius: var(--radius-sm);
  color: var(--fg-2);
  font-size: var(--fs-sm);
  text-decoration: none;
}

.ds-navigation-menu__link:hover {
  background: var(--bg-subtle);
  color: var(--fg);
}

.ds-navigation-menu__link--active {
  background: var(--bg-subtle);
  color: var(--fg);
  font-weight: var(--fw-medium);
}

.ds-navigation-menu__link:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: -2px;
}

/* ==========================================================================
   Popover
   ========================================================================== */

/* The trigger is a control and takes --border-control; the floating panel
   below is a surface edge over a shadow and stays decorative. */
.ds-popover__trigger {
  align-items: center;
  background: transparent;
  border: 1px solid var(--border-control);
  border-radius: var(--radius-sm);
  color: var(--fg);
  cursor: pointer;
  display: inline-flex;
  font-family: var(--font-sans);
  font-size: var(--fs-sm);
  min-height: 44px;
  padding: var(--space-2) var(--space-4);
}

.ds-popover__trigger:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 2px;
}

.ds-popover__content {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-md);
  max-width: 320px;
  padding: var(--space-4);
}

/* ==========================================================================
   Sheet
   ========================================================================== */

.ds-sheet {
  background: transparent;
  border: none;
  height: 100%;
  margin: 0;
  max-height: 100%;
  max-width: 100%;
  overflow: visible;
  padding: 0;
  position: fixed;
  width: 100%;
}

.ds-sheet::backdrop {
  background: oklch(0% 0 0 / 0.45);
}

.ds-sheet__inner {
  background: var(--surface);
  border: 1px solid var(--border);
  box-shadow: var(--shadow-lg);
  display: grid;
  gap: var(--space-4);
  height: 100%;
  padding: var(--space-6);
}

.ds-sheet--right .ds-sheet__inner {
  margin-left: auto;
  max-width: 400px;
  width: 100%;
}

.ds-sheet--left .ds-sheet__inner {
  margin-right: auto;
  max-width: 400px;
  width: 100%;
}

.ds-sheet--top .ds-sheet__inner {
  margin-bottom: auto;
  max-height: 320px;
  width: 100%;
}

.ds-sheet--bottom .ds-sheet__inner {
  margin-top: auto;
  max-height: 320px;
  width: 100%;
}

.ds-sheet__header {
  display: grid;
  gap: var(--space-1);
}

.ds-sheet__title {
  color: var(--fg);
  font-family: var(--font-sans);
  font-size: var(--fs-lg);
  font-weight: var(--fw-semibold);
  letter-spacing: var(--tr-tight);
  margin: 0;
}

.ds-sheet__description {
  color: var(--fg-2);
  font-family: var(--font-sans);
  font-size: var(--fs-sm);
  line-height: var(--lh-normal);
  margin: 0;
}

.ds-sheet__footer {
  display: flex;
  gap: var(--space-3);
  justify-content: flex-end;
  margin-top: auto;
}

/* ==========================================================================
   Table
   ========================================================================== */

/* -- Literal <table> mode ------------------------------------------------ */

table.ds-table {
  width: 100%;
  border-collapse: collapse;
  font-size: var(--fs-sm);
}

table.ds-table th {
  text-align: left;
  padding: var(--space-2) var(--space-3);
  border-bottom: 1px solid var(--border-strong);
  color: var(--fg-2);
  font-weight: var(--fw-medium);
}

table.ds-table td {
  padding: var(--space-2) var(--space-3);
  border-bottom: 1px solid var(--border);
  color: var(--fg);
}

/* ==========================================================================
   Tabs
   ========================================================================== */

.ds-tabs {
  display: grid;
  gap: var(--space-4);
}

.ds-tabs__list {
  border-bottom: 1px solid var(--border);
  display: flex;
  gap: var(--space-1);
}

.ds-tabs__trigger {
  background: transparent;
  border: none;
  border-bottom: 2px solid transparent;
  color: var(--fg-2);
  cursor: pointer;
  font-family: var(--font-sans);
  font-size: var(--fs-sm);
  font-weight: var(--fw-medium);
  margin-bottom: -1px;
  min-height: 44px;
  padding: var(--space-3) var(--space-4);
  transition: color var(--dur-fast) var(--ease-out);
}

.ds-tabs__trigger:hover {
  color: var(--fg);
}

.ds-tabs__trigger:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 2px;
}

/* The underline is the only thing marking the active tab, so it has to follow
   the theme: --ink-1000 was a black rule on a black page, 1.00:1. */
.ds-tabs__trigger--active {
  border-bottom-color: var(--fg); /* 21:1 both themes */
  color: var(--fg);
}

.ds-tabs__content {
  padding-top: var(--space-2);
}

/* ==========================================================================
   Toast
   ========================================================================== */

.ds-toast-viewport {
  bottom: var(--space-6);
  display: grid;
  gap: var(--space-3);
  position: fixed;
  right: var(--space-6);
  width: min(360px, calc(100vw - var(--space-8)));
  z-index: var(--z-toast);
}

.ds-toast {
  align-items: flex-start;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-md);
  display: flex;
  gap: var(--space-3);
  padding: var(--space-4);
}

/* The stripe is the toast's whole edge, and it is the only thing that says
   which kind of toast this is before a word is read — a meaningful non-text
   graphic under SC 1.4.11, so each colour has to clear 3:1 against the page it
   sits on. Three of the four fills do on their own: success 5.17:1 light /
   4.06:1 dark, danger 5.41 / 3.88, info 5.06 / 4.15.

   Warning is the asymmetry, and it is deliberate. --status-warning is 1.87:1
   on white, because a yellow pushed dark enough to clear 3:1 there is olive
   and no longer reads as caution. Painted bare it would be a stripe a reader
   cannot find on a white page. So warning — and only warning — reaches for
   --status-warning-border (3.76:1 light, 11.22:1 dark), the token that exists
   to carry the floor the fill cannot. In dark the border token IS the fill,
   which is why this costs nothing there. */
.ds-toast--success {
  border-color: var(--status-success); /* 5.17:1 light, 4.06:1 dark */
}

.ds-toast--warning {
  border-color: var(--status-warning-border); /* 3.76:1 light, 11.22:1 dark */
}

.ds-toast--danger {
  border-color: var(--status-danger); /* 5.41:1 light, 3.88:1 dark */
}

.ds-toast--info {
  border-color: var(--status-info); /* 5.06:1 light, 4.15:1 dark */
}

.ds-toast__content {
  display: grid;
  flex: 1;
  gap: var(--space-1);
}

.ds-toast__title {
  color: var(--fg);
  font-family: var(--font-sans);
  font-size: var(--fs-sm);
  font-weight: var(--fw-semibold);
}

.ds-toast__description {
  color: var(--fg-2);
  font-family: var(--font-sans);
  font-size: var(--fs-sm);
  line-height: var(--lh-normal);
}

.ds-toast__close {
  align-items: center;
  background: transparent;
  border: none;
  color: var(--fg-3);
  cursor: pointer;
  display: inline-flex;
  /* --mark-size is read from the type ramp directly rather than as `1em`, so
     the mark keeps its size even if a consumer restyles this control's text.

     No font-family here, deliberately. .ds-chip__remove declares one because it
     predates #170, which puts a single layered `font-family: inherit` on native
     form controls in tokens.css — restating it per control would be five more
     copies of a rule being centralised next door, and unlayered ones that would
     win against it. This control paints a mark and no text, so it has nothing
     of its own to typeset either way. */
  --mark-size: var(--fs-lg);
  font-size: var(--fs-lg);
  justify-content: center;
  line-height: 1;
  min-height: 44px;
  min-width: 44px;
  padding: 0;
}

.ds-toast__close:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 2px;
}

/* The action is a control; the toast's own edge and its status stripe stay
   as they are — three of the four stripes clear 3:1 in both themes as the bare
   fill, and warning gets there through --status-warning-border. */
.ds-toast__action {
  background: transparent;
  border: 1px solid var(--border-control);
  border-radius: var(--radius-sm);
  color: var(--fg);
  cursor: pointer;
  font-family: var(--font-sans);
  font-size: var(--fs-sm);
  min-height: 44px;
  padding: var(--space-2) var(--space-3);
}

/* ==========================================================================
   Tooltip
   ========================================================================== */

.ds-tooltip__trigger {
  display: inline-flex;
}

/* A tooltip has no border and no shadow, so its fill is the only thing
   separating it from the page. Fixed at --ink-1000 that was a black panel on
   a black page — 1.00:1, and the tooltip simply disappeared in dark mode.
   The inverse pair flips with the theme: 21:1 against the page, 21:1 text. */
.ds-tooltip__content {
  background: var(--bg-inverse);
  border-radius: var(--radius-sm);
  color: var(--fg-inverse);
  font-family: var(--font-sans);
  font-size: var(--fs-xs);
  max-width: 240px;
  padding: var(--space-2) var(--space-3);
  pointer-events: none;
}

/* ==========================================================================
   VirtualList
   ========================================================================== */

.ds-virtual-list {
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  background: var(--surface);
}

.ds-virtual-list__row {
  display: flex;
  align-items: center;
  padding: 0 var(--space-3);
  border-bottom: 1px solid var(--border);
}

/* ==========================================================================
   VirtualTable
   ========================================================================== */

/* -- Virtualized ARIA-grid mode ------------------------------------------- */

.ds-table--virtual {
  display: block;
  font-size: var(--fs-sm);
}

.ds-table__vrow {
  display: grid;
  align-items: center;
}

.ds-table__vrow--head {
  border-bottom: 1px solid var(--border-strong);
}

.ds-table__vrow:not(.ds-table__vrow--head):not(.ds-table__vrow--empty) {
  border-bottom: 1px solid var(--border);
}

.ds-table__vrow--empty {
  display: block;
}

.ds-table__vcell {
  padding: var(--space-2) var(--space-3);
  color: var(--fg);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.ds-table__vcell--head {
  color: var(--fg-2);
  font-weight: var(--fw-medium);
}

.ds-table__vcell--empty {
  padding: 0;
  white-space: normal;
}

.ds-table__vbody {
  width: 100%;
}

/* ==========================================================================
   Table — chrome shared by Table and VirtualTable
   ========================================================================== */

.ds-table-wrapper {
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
  width: 100%;
  overflow-x: auto;
}

.ds-table-wrapper__footer {
  display: flex;
  justify-content: flex-end;
}

.ds-table__filter {
  max-width: 320px;
}

/* -- Sort control, shared by both table modes ----------------------------- */

/* min-height is load-bearing, not decorative. This control is
   data-touch-target="dense", so #116 measures it against --target-min. Its
   painted height used to be entirely font-derived — 23.09px from the inner
   <span>'s line box at --fs-sm — and it cleared 24 only because the probe at
   centreY - 12 lands 0.45px above the border box and an inline box's hit
   region bleeds that far. Stable across 40 subpixel/dpr permutations, and
   still a rounding artefact: any change to the header line-height, or a
   consumer whose fallback font resolves differently, would have turned it red
   and looked like a contract regression. The floor now lives in CSS. */
.ds-table__sort {
  display: inline-flex;
  align-items: center;
  min-height: var(--target-min);
  gap: var(--space-1);
  background: none;
  border: none;
  padding: 0;
  margin: 0;
  font: inherit;
  color: inherit;
  cursor: pointer;
}

.ds-table__sort:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 2px;
}

.ds-table__sort-icon {
  font-size: var(--fs-2xs);
  color: var(--fg-3);
}

