/* design-system: page-library v1 | tokens.css */
/* Token sheet for the page library: three tiers, and nothing else.
 *
 * This banner deliberately does not name the file. The fleet linter treats a first line that
 * names tokens.css/base.css as a COPY of the canonical fleet system, which this is not: it is a
 * second vocabulary with its own names. Claim "copy" and the linter correctly demands it match
 * the fleet, and 212 findings follow. See NOTES.md, "A fork, not a copy".

WHY THREE TIERS
Tier 1 is raw value. Tier 2 is a role a product may use. Tier 3 is a component's own
decision. A component may read tier 2 or 3 and never tier 1, which is what stops a page
from picking a specific grey. The build fails on a tier-1 read outside this file.

WHY THE NAMES ARE ROLES, NOT HUES
`--ok` survives a rebrand and `--green-600` does not. The only hue-named tokens here are
tier 1, which nothing outside this file may use.

WHY HEX AND NOT OKLCH
oklch() is the better authoring space and it is Baseline. It is not here yet because the
contrast gate parses hex, and a token layer the gate cannot measure is a token layer the
gate cannot protect. That is a stated trade, not an oversight: see NOTES.md, D3.
*/

:root {
  /* ── tier 1: primitive ─────────────────────────────────────────────────── */
  --gray-0:#ffffff;   --gray-25:#f7f7f8;  --gray-50:#f2f2f4;  --gray-100:#e9e9ed;
  --gray-200:#e6e6e9; --gray-300:#d4d4d8; --gray-400:#a1a1aa; --gray-500:#71717a;
  --gray-600:#52525b; --gray-700:#3f3f46; --gray-900:#18181b; --gray-950:#111114;
  --indigo-50:#eef0ff; --indigo-500:#4f46e5; --indigo-600:#4338ca;
  --green-50:#eaf6ee;  --green-600:#15803d;  --green-200:#cfe7d8;
  --amber-50:#fdf4e7;  --amber-700:#b45309;  --amber-200:#f0dcc0;
  --red-50:#fdecec;    --red-700:#b91c1c;    --red-200:#f2cccc;

  /* The terminal chrome, deliberately outside the theme: a console is dark on a light page and on a
     dark one, so these are primitives that no theme re-points. They live here because a literal in
     components.css is a literal, even when it is the right one. */
  --term-bg:#0c0c0f;   --term-bar:#14141a;   --term-line:#1f1f25;  --term-line2:#23232b;
  --term-lamp:#2e2e38; --term-ink:#d6d6db;   --term-dim:#8b8b96;
  --term-ok:#7ee2a8;   --term-warn:#e2c07e;  --term-alert:#f08c8c; --term-info:#8ab4f8;

  /* type: one ladder, ten steps, roles not ratios */
  /* his verdict 2026-10-22: the 11px mono micro was hard to read, so micro is 12px sans.
     Mono is for values (code, kbd, addresses), never a whole step below --fs-sm. */
  --fs-micro:12px;  --fs-label:12px; --fs-sm:13px;  --fs-base:14px; --fs-prose:16px;
  --fs-h3:18px;     --fs-h2:22px;    --fs-h1:28px;  --fs-display:40px; --fs-hero:52px;
  --lh-tight:1.12;  --lh-body:1.45;  --lh-prose:1.6;
  --tracking-tight:-.02em; --tracking-hero:-.03em;

  /* space: a ladder of eight, 4px base, nothing between */
  --space-1:4px;  --space-2:8px;  --space-3:12px; --space-4:16px;
  --space-5:24px; --space-6:32px; --space-7:48px; --space-8:64px;

  --radius-sm:6px; --radius:10px; --radius-lg:14px; --radius-box:16px; --radius-pill:999px;
  --border:1px;

  /* motion: four durations, three curves */
  --dur-0:.01ms; --dur-1:90ms; --dur-2:150ms; --dur-3:220ms; --dur-4:320ms;
  /* the ladder, not arbitrary z: a raw z-index is how a dropdown ends up under a modal */
  --z-1:1; --z-2:10; --z-3:50; --z-4:100; --z-5:1000;
  --ease-out:cubic-bezier(.2,0,.1,1);
  --ease-in-out:cubic-bezier(.4,0,.2,1);
  --ease-accelerate:cubic-bezier(.4,0,1,1);   /* exits only, never an entrance */

  /* ── tier 2: semantic, light ───────────────────────────────────────────── */
  --bg:var(--gray-0);
  --sunken:#f4f4f5; /* daisy base-200, his light pick 2026-10-22 */
  --raised:var(--gray-0);
  --ink:var(--gray-900);

  --ink2:var(--gray-700);
  --muted:#6b7280; /* daisy's blue-grey, his light pick */
  --line:#e5e5e5; /* daisy base-200 border */
  --line2:var(--gray-300);

  --accent:var(--indigo-500);
  --accent-hover:var(--indigo-600);
  --accent-ink:var(--gray-0);
  --accent-soft:var(--indigo-50);
  /* THE INK ON A GRADIENT GROUND, which is a different surface from the flat accent even though one
     component draws both: `.btn.gradient` and `.brand .mark` paint `--accent-grad`, `.btn.primary` paints
     `--accent`. One ink for the two is how zpe's mark came to read 2.28:1: #0b0713 is right on the flat
     #a78bfa (7.32:1) and a smudge on the violet stop of #6b21a8 -> #3b5bdb. Defaults to the flat ink,
     which is what every theme rendered before this token existed, so only a theme that paints a DARK
     gradient re-points it. A gradient cannot be graded by a `@contrast` pair, so the theme that sets it
     carries the two stop measurements in prose. */
  --accent-grad-ink:var(--accent-ink);

  --ok:var(--green-600);     --ok-soft:var(--green-50);     --ok-line:var(--green-200);
  --warn:var(--amber-700);   --warn-soft:var(--amber-50);   --warn-line:var(--amber-200);
  --alert:var(--red-700);    --alert-soft:var(--red-50);    --alert-line:var(--red-200);

  --font-sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Inter,sans-serif;
  --font-mono:ui-monospace,SFMono-Regular,Menlo,monospace;

  --scrim:rgba(16,16,20,.5);
  --shadow:0 1px 2px rgba(16,16,20,.05),0 1px 3px rgba(16,16,20,.04);
  --shadow-lg:0 12px 32px rgba(16,16,20,.10),0 2px 8px rgba(16,16,20,.06);
  --shadow-modal:0 24px 64px rgba(0,0,0,.28);
  --ring:0 0 0 2px var(--bg),0 0 0 4px var(--accent);

  --measure:34em;
  --control-h:36px;
  --control-h-sm:28px;
  --row-h:44px;

  /* ── tier 3: component ─────────────────────────────────────────────────── */
  --btn-bg:var(--raised);
  --btn-bg-hover:var(--sunken);
  --btn-line:var(--line2);
  --btn-radius:var(--radius-sm);
  --btn-fs:13.5px;
  --btn-weight:600;   /* 500 was reading as a label, not an action. The original's CTA was 600. */
  --btn-pad:14px;

  --field-line:var(--line2);
  --field-bg:var(--raised);
  --field-radius:var(--radius-sm);

  --card-bg:var(--raised);
  --card-line:var(--line);
  --card-radius:var(--radius-box); /* 16px, his pick 2026-10-22: daisy's card cornering */
  --card-pad:18px;
  --gap-card:var(--space-4);   /* the floor between two cards. 12px reads as one object split in two. */

  --badge-h:22px;
  --badge-radius:var(--radius-pill);
  --badge-fs:11.5px;

  --table-fs:13.5px;
  --table-head-fs:12px;
  --table-cell-pad:13px 12px;

  /* THE ESTATE BAR, adopted from zpe-nav's own sheet on 2026-10-28. Its measures were literals in that file and
     they are the bar's contract: 52px tall, 11px uppercase items at 0.2em, a 68rem line that falls back to the
     gutter, and a reserve for the empty host before the script lands. A component reads tokens, so they live here. */
  --bar-h:52px;
  --bar-reserve:3.4rem;
  --bar-fs:11px;
  --bar-track:0.2em;
  --bar-track-acct:0.16em;
  --bar-gutter:clamp(1.25rem, 4vw, 2.5rem);
  --bar-line-h:68rem; /* the 48px rung, his default 2026-10-22; compact 32px lives in the data-density block */
  /* THE ROW'S TARGET, AND THE COLOUR IT IS PAINTED. Both are the bar's own, so neither follows a page's
     `--row-h` (compact density takes that to 32) nor its `--muted` (a dark page that carries no `data-theme`
     inherits the LIGHT palette's #6b7280 and paints it on #050507). Measured on https://zpe.vc/ 2026-10-31,
     where the bar's items read rgb(107,114,128) on #050507 = 4.21:1 and every row was 24px tall.

     THE INK IS A PAIR, AND DARK IS ITS DEFAULT. #8a8f9b is measured on dark grounds and only on dark grounds:
     #050507 6.29:1, black 6.48:1, #0a0a0f 6.10:1, #1d1d23 5.18:1. The claim this comment carried until
     2026-11-01, "one row has one colour on every surface", was true there and false on every light page:
     /p/audit read 3.23:1 on #ffffff and /invoices 3.00:1 on #f8f6f1, both at 11px, where AA asks 4.5:1.
     Dark stays the default because the bar cannot read a ground it was not given: the door (https://zpe.vc/)
     answers with no `data-theme` at all and paints #050507, so a light default would paint the light value on
     a near-black field. A light page DECLARES its ground once, `<html data-surface="light">`, and the rule
     below re-points this one token. */
  --bar-item-h:44px;
  --bar-item-ink:#8a8f9b;
  /* The contrast contract, per scope. Body text is what these floors protect: 7 for the primary
     ink on its own surface, 4.5 for secondary and muted text, 4.5 for a status hue as text. The
     accent is declared at 3 on purpose: it carries fills, rules and emphasis, and its own label
     pair (accent-ink on accent) is the one that has to clear 4.5 as text. */
  /* @contrast --ink on --bg 7
     @contrast --ink on --raised 7
     @contrast --ink2 on --bg 4.5
     @contrast --muted on --bg 4.5
     @contrast --muted on --raised 4.5
     @contrast --accent on --bg 3
     @contrast --accent-ink on --accent 4.5
     @contrast --ok on --bg 4.5
     @contrast --warn on --bg 4.5
     @contrast --alert on --bg 4.5 
     @contrast --ink on --accent-soft 4.5
    */
}

/* A LIGHT GROUND IS DECLARED, ONCE, IN MARKUP. `<html data-surface="light">` re-points the one token whose value
   is a ground decision: the bar's ink. Measured 2026-11-01, #646c7a on the grounds this estate actually paints:
   #ffffff 5.29:1 (/p/audit), #f8f6f1 4.90:1 (the invoices' warm paper), #f5f4ed 4.80:1 and #faf9f5 5.02:1
   (bakery), #f4f4f5 4.81:1 (--sunken), #f2f2f4 4.73:1 (--gray-50). The lightest of those is the value's floor:
   4.73 against the 4.5 an 11px uppercase row has to clear, which is the margin the #776f63 precedent lacked.

   WHY AN ATTRIBUTE, AND NOT A PAGE'S `:root { --bar-item-ink: … }`: the bar's sheet loads after the page's
   `<style>`, so equal-specificity declarations resolve on document order and the library wins. WHY NOT
   `light-dark()`: the door answers `color-scheme: normal` while painting #050507, so the UA's preference would
   be wrong on the estate's busiest dark surface, and the bar would paint its light ink on a near-black field.

   A ground that is neither the dark default nor one of the light ones above (a page carrying its own palette)
   keeps the escape hatch zpe-invoices measured: one declaration on the bar element,
   `body .zpe-nav { --bar-item-ink: … }`, with that page's own measured pair. */
:root[data-surface="light"] {
  --bar-item-ink:#646c7a;
  /* @contrast --bar-item-ink on --bg 4.5 */
}

/* A theme re-points tier 2 and declares nothing new. A theme that ADDS a token widens the
   vocabulary for every surface and is how a second palette gets in. */
:root[data-theme="dark"] {
  /* THE UA MUST BE TOLD, or every native part the sheet does not paint stays light. Measured 2026-10-28 on a
     consumer's form in this theme: `input[type="date"]` rendered `background rgb(255,255,255)` and
     `color rgb(0,0,0)`, a white box in a near-black page, because the sheet styles the text-ish inputs and not
     this one, and the theme never declared its scheme. `tape` and `amp` already carried this line; this theme did
     not, which is how a whole palette came to keep light scrollbars, a light calendar popup and light form
     controls. Declaring it here fixes the popup and the scrollbars as well, and costs no token. */
  color-scheme: dark;
  --bg:var(--gray-950);
  --sunken:#17171c;
  --raised:#1d1d23;
  --ink:#f2f2f5;
  --ink2:#d4d4d8;
  --muted:#9b9ba4;
  --line:#30303a;
  --line2:#4d4d5a;

  --accent:#8b8bf5;
  --accent-hover:#a3a3ff;
  --accent-ink:var(--gray-950);
  --accent-soft:#23233a;
  /* The three role tokens a dark page needs to stop reading as a void, taken from THIS theme's own
     accent rather than copied from another theme's violet. Measured on the launcher (2026-10-27): the
     same page with a colour-only background read as flat, and the same page with this wash read as a
     room. `--page-glow` is consumed by base.css's body rule, so a page that sets it needs no CSS. */
  --accent-grad:linear-gradient(96deg,color-mix(in srgb,var(--accent) 78%,#3b5bdb),var(--accent));
  --accent-grad-text:linear-gradient(96deg,color-mix(in srgb,var(--accent) 72%,#b3b3ff) 0%,
                                          var(--accent) 52%,
                                          color-mix(in srgb,var(--accent) 30%,var(--ink)) 100%);
  --page-glow:radial-gradient(920px 500px at 12% -8%,color-mix(in srgb,var(--accent) 17%,transparent),transparent 62%),
              radial-gradient(780px 420px at 94% 2%,color-mix(in srgb,var(--accent) 9%,transparent),transparent 58%);

  --ok:#6ee7a0;      --ok-soft:#12251a;      --ok-line:#1f4430;
  --warn:#f0b46b;    --warn-soft:#2a1f10;    --warn-line:#4a3618;
  --alert:#f08c8c;   --alert-soft:#2a1416;   --alert-line:#4a2222;

  --scrim:rgba(0,0,0,.62);
  --shadow:0 1px 2px rgba(0,0,0,.4),0 1px 3px rgba(0,0,0,.3);
  --shadow-lg:0 12px 32px rgba(0,0,0,.5),0 2px 8px rgba(0,0,0,.35);
  --ring:0 0 0 2px var(--bg),0 0 0 4px var(--accent);
  /* The contrast contract, per scope. Body text is what these floors protect: 7 for the primary
     ink on its own surface, 4.5 for secondary and muted text, 4.5 for a status hue as text. The
     accent is declared at 3 on purpose: it carries fills, rules and emphasis, and its own label
     pair (accent-ink on accent) is the one that has to clear 4.5 as text. */
  /* @contrast --ink on --bg 7
     @contrast --ink on --raised 7
     @contrast --ink2 on --bg 4.5
     @contrast --muted on --bg 4.5
     @contrast --muted on --raised 4.5
     @contrast --accent on --bg 3
     @contrast --accent-ink on --accent 4.5
     @contrast --ok on --bg 4.5
     @contrast --warn on --bg 4.5
     @contrast --alert on --bg 4.5 
     @contrast --ink on --accent-soft 4.5
    */
}

/* A third theme, carried over from the fleet's warm-paper palette because a consumer already ships
   it and dropping it would be a visible change to a live page rather than a port. It re-points the
   semantic tier only, and it re-points the SANS slot to a serif on purpose: every page using it is
   prose first, so the reading face is the page face. */
:root[data-theme="bakery"] {
  --bg:#f5f4ed; --sunken:#edeae0; --raised:#faf9f5;
  --ink:#141413; --ink2:#3a3936; --muted:#5e5d59;
  --line:#e8e6dc; --line2:#d9d5c7;
  --accent:#a8412a; --accent-hover:#8f351f; --accent-ink:#ffffff; --accent-soft:#f2e6e1;
  --ok:#2f6f33;    --ok-soft:#e2efe4;    --ok-line:#c6ddc9;
  --warn:#8a5300;  --warn-soft:#f6ecd9;  --warn-line:#e0cba8;
  --alert:#b53333; --alert-soft:#f7e9e6; --alert-line:#d9aa9f;
  --font-sans:Georgia,"Iowan Old Style","Times New Roman",serif;
  --shadow:0 1px 2px rgba(60,50,35,.06),0 1px 3px rgba(60,50,35,.05);
  /* The contrast contract, per scope. Body text is what these floors protect: 7 for the primary
     ink on its own surface, 4.5 for secondary and muted text, 4.5 for a status hue as text. The
     accent is declared at 3 on purpose: it carries fills, rules and emphasis, and its own label
     pair (accent-ink on accent) is the one that has to clear 4.5 as text. */
  /* @contrast --ink on --bg 7
     @contrast --ink on --raised 7
     @contrast --ink2 on --bg 4.5
     @contrast --muted on --bg 4.5
     @contrast --muted on --raised 4.5
     @contrast --accent on --bg 3
     @contrast --accent-ink on --accent 4.5
     @contrast --ok on --bg 4.5
     @contrast --warn on --bg 4.5
     @contrast --alert on --bg 4.5 
     @contrast --ink on --accent-soft 4.5
    */
}

/* A third dark reading, and the first THEME that exists to make a product distinct rather than to
   suit a surface class. Near-black canvas, white headline, one violet accent. The point of a theme
   is that the components do not change: a page that picks `tape` gets the same button, table and
   badge, in this product's colour. Sameness across a portfolio is a theme problem, not a component
   problem, which is why this is 20 lines and not a second stylesheet. */
:root[data-theme="tape"] {
  color-scheme: dark;
  /* R4 verdict 2026-10-22, final: his picks are shadcn dark and daisy light, so the reference
     palettes are ADOPTED (the falsifier in DECISIONS.md fired). Dark = shadcn neutrals: cards
     separate by border, not fill; the tape identity layer (gradient, glow, accent) is untouched. */
  --bg:#09090b; --sunken:#18181b; --raised:#09090b;
  --ink:#fafafa; --ink2:#f4f4f5; --muted:#a1a1aa;
  /* R6 follow-up: #333333 measured 1.57:1 on the page, so a control border was invisible. A border
     is a UI boundary, not decoration, and the floor for it is 3:1; #606060 measures 3.16. */
  --line:#27272a; --line2:#606060;
  --accent:#7c3aed; --accent-hover:#8b5cf6; --accent-ink:#ffffff; --accent-soft:#1e1b2e;
  /* a gradient is a product's face, so it is a token: a theme may set one, components may not
     invent one. Both stops are dark enough for white text at AA, which is checked by hand below. */
  --accent-grad:linear-gradient(96deg,#7c3aed,#3b82f6);
  /* the same ramp as a TEXT fill. Three stops, because two read as a flat wash at 56px: the eye
     needs the violet to arrive, the blue to carry, and the cyan to land. */
  --accent-grad-text:linear-gradient(96deg,#8b5cf6 0%,#3b82f6 52%,#22d3ee 100%);
  /* the page wash. Not decoration: it is what stops a near-black page reading as a void, and it is
     the difference between this design and a flat #0a0a0a. */
  --page-glow:radial-gradient(1000px 520px at 12% -8%,rgba(124,92,255,.20),transparent 60%),
              radial-gradient(900px 430px at 94% 2%,rgba(34,211,238,.12),transparent 55%);
  --ok:#4ade80;    --ok-soft:#0f2318;    --ok-line:#1f4430;
  --warn:#fbbf24;  --warn-soft:#261d09;  --warn-line:#4a3618;
  --alert:#f87171; --alert-soft:#260f0f; --alert-line:#4a2222;
  --scrim:rgba(0,0,0,.68);
  /* The contrast contract, per scope. Body text is what these floors protect: 7 for the primary
     ink on its own surface, 4.5 for secondary and muted text, 4.5 for a status hue as text. The
     accent is declared at 3 on purpose: it carries fills, rules and emphasis, and its own label
     pair (accent-ink on accent) is the one that has to clear 4.5 as text. */
  /* @contrast --ink on --bg 7
     @contrast --ink on --raised 7
     @contrast --ink2 on --bg 4.5
     @contrast --muted on --bg 4.5
     @contrast --muted on --raised 4.5
     @contrast --accent on --bg 3
     @contrast --accent-ink on --accent 4.5
     @contrast --ok on --bg 4.5
     @contrast --warn on --bg 4.5
     @contrast --alert on --bg 4.5 
     @contrast --ink on --accent-soft 4.5
    */
}

/* A fourth theme, and the second that exists for a PRODUCT rather than a surface class: ampWe's
   own palette, ported 2026-10-25. Near-black canvas, one violet accent, mint for ok. It lives here
   rather than in the app because a theme is data and its values are tokens: the app then carries no
   colour literal at all, and this palette stays readable by whatever gate reads this file. The app
   reads these roles through its tailwind config, so its markup names a role and never a hue. */
:root[data-theme="amp"] {
  color-scheme: dark;
  --bg:#0a0a0f; --sunken:#12121a; --raised:#1a1a26;
  /* --muted moved from #6b7280 (4.09:1 on this page, under the body floor) to the nearest step
     above 4.5, which is the value the declared contract requires. */
  --ink:#f0f0ff; --ink2:#9999bb; --muted:#7d8695;
  --line:#2a2a3e; --line2:#3a3a5e;
  --accent:#6c63ff;   /* white on this violet measured 4.32:1, so the CTA label failed AA. A light accent takes the
     page's dark ink, which is the library's own convention in the dark theme. */
  --accent-hover:#5a52ef; --accent-ink:var(--bg); --accent-soft:#1e1c3f;
  /* the waveform and the glow are this product's face, so they are tokens, not page styles */
  --accent-grad:linear-gradient(to top,#6c63ff,#8b84ff);
  --ok:#00e5a0;    --ok-soft:#091919;    --ok-line:#134a39;
  --warn:#ffb347;  --warn-soft:#191412;  --warn-line:#4a3618;
  --alert:#ff4d6a; --alert-soft:#1b0f15; --alert-line:#4a2222;
  --scrim:rgba(0,0,0,.7);
  /* The contrast contract, per scope. Body text is what these floors protect: 7 for the primary
     ink on its own surface, 4.5 for secondary and muted text, 4.5 for a status hue as text. The
     accent is declared at 3 on purpose: it carries fills, rules and emphasis, and its own label
     pair (accent-ink on accent) is the one that has to clear 4.5 as text. */
  /* @contrast --ink on --bg 7
     @contrast --ink on --raised 7
     @contrast --ink2 on --bg 4.5
     @contrast --muted on --bg 4.5
     @contrast --muted on --raised 4.5
     @contrast --accent on --bg 3
     @contrast --accent-ink on --accent 4.5
     @contrast --ok on --bg 4.5
     @contrast --warn on --bg 4.5
     @contrast --alert on --bg 4.5 
     @contrast --ink on --accent-soft 4.5
    */
}

/* Density is a data attribute on a container, not a second stylesheet: it moves the row
   height and the control height and nothing else. Text size never changes with density. */
[data-density="compact"] {
  --control-h:28px;
  --control-h-sm:24px;
  --row-h:32px;
  --table-cell-pad:6px 10px;
  --card-pad:14px;
  --space-5:18px;
  --space-6:24px;
}

/* design-system: page-library v1 | base.css */
/* Structure for the page library: reset, type, and the four shells.

WHAT THIS FILE OWNS
Structure a page needs before any component: the reset, the reading defaults, focus, the
skip link, the layout grid, and the three page shells (app, marketing, auth). It owns no
colour of its own; every value comes from tokens.css.

WHAT IT DELIBERATELY DOES NOT OWN
Components. A `.card` is not here. If it were, every page would inherit a card whether it
wanted one, and the component tier would have nowhere to live.
*/

*, *::before, *::after { box-sizing: border-box; }

html { -webkit-text-size-adjust: 100%; }

body {
  margin: 0;
  background: var(--bg);
  color: var(--ink);
  font: var(--fs-base)/var(--lh-body) var(--font-sans);
  -webkit-font-smoothing: antialiased;
  /* a theme may lay a wash under the page. Fixed, so the colour stays where the theme put it
     instead of sliding with the content. */
  background-image: var(--page-glow, none);
  background-attachment: fixed;
  background-repeat: no-repeat;
}

h1, h2, h3, h4 { margin: 0; font-weight: 600; letter-spacing: var(--tracking-tight); }
h1 { font-size: var(--fs-h1); line-height: 1.18; }
/* a marker class, not a size: the rendered gate measures the title through this hook, and
   the page head sets its own step, so one page cannot drift from another silently */
h1.m-title { font-size: var(--fs-h1); line-height: 1.18; letter-spacing: var(--tracking-tight); }
.pagehead .t h1.m-title { font-size: var(--fs-h2); }
h2 { font-size: var(--fs-h3); line-height: 1.3; }
h3 { font-size: var(--fs-base); line-height: 1.35; }
p { margin: 0; }

a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
/* WCAG 2.5.8: an inline-flex item is blockified, so the 24px floor applies and the inline-link
   exception does not. Measured on two live article pages before this line existed. */
.m-prose a, .read a, .lede a, .read-bar a, .mfoot a, .cite a,
.micro a, a.micro, a.small, .note a, .hint a, .top .crumb a { display: inline-flex; align-items: center; min-height: 24px; }

/* AND A LINK INSIDE A QUIET PARAGRAPH IS STILL A LINK. `.note` is the system's small print and it carries the
   one action a person is told to take ("Enrol it at the door", "the ways into this account"); both measured
   19px tall on the settings page. Same rule, same floor. */
/* A SMALL LINK IS STILL A LINK. `a.small` is the size a section header gives a control that sits opposite
   a heading, and it measured 161x19 at 390px on the launcher: 19 tall is under the 24px floor, and the
   inline-link exception does not cover a control. Same rule as the micro link above, same reason. */

/* Focus is a single rule for the whole system, and it is the NATIVE outline: a native ring
   survives a component that overrides box-shadow, which a painted ring does not. A component
   that removes it is a defect; one that invents its own is a second focus language. */
:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: var(--radius-sm);
}

.skip {
  position: absolute; left: -9999px;
  display: inline-flex; align-items: center;
  min-height: 24px; padding: 0 var(--space-2);
}
.skip:focus {
  left: var(--space-3); top: var(--space-3); z-index: var(--z-4);
  background: var(--raised);
  border: var(--border) solid var(--accent);
  border-radius: var(--radius-sm);
}

.hr { height: var(--border); background: var(--line); border: 0; margin: 0; }
/* a note is a paragraph that qualifies something else: smaller, quieter, capped at a readable
   measure so it never runs the width of a wide screen */
.note { font-size: 12.5px; color: var(--muted); max-width: 46em; }

/* ── text roles ──────────────────────────────────────────────────────────── */
.prose, .m-prose { max-width: var(--measure); color: var(--ink2); }
.muted { color: var(--muted); }
.small { font-size: var(--fs-sm); }
.tiny  { font-size: 11.5px; }
/* a small link is still a target: 24px of height even when the type is 11px */
a.tiny, .tiny a, .small a { display: inline-flex; align-items: center; min-height: 24px; }
.mono  { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
/* A micro label is the one type role that says METADATA rather than prose: uppercase, mono, tracked.
   It is not decoration. On a dense page it is what separates "43 apps" and "the keys" from the
   sentences beside them, and a sentence-case version of it reads as body copy that lost its size. */
.micro {
  font: 500 11.5px/1 var(--font-mono); letter-spacing: .12em; text-transform: uppercase;
  color: var(--muted);
}
.num   { font-variant-numeric: tabular-nums; }
.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: calc(-1 * var(--border));
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}

/* ── layout ──────────────────────────────────────────────────────────────── */
.grid  { display: grid; gap: var(--gap-card); }
.g2    { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.g3    { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.g4    { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.split { display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr); gap: var(--space-5); }
/* A grid child defaults to min-width:auto, so one wide table widens its column past 1fr and
   the page scrolls sideways. Measured on six pages before this line existed. */
.grid > *, .split > *, .g2 > *, .g3 > *, .g4 > * { min-width: 0; }

.tblwrap { overflow-x: auto; max-width: 100%; }

/* ── layout utilities ────────────────────────────────────────────────────── */
/* Tokens, not values: .mt-3 is a step on the ladder, and a page that needs 14px has to argue
   with the ladder instead of writing 14px. */
.row    { display: flex; align-items: center; gap: var(--space-2); }
.row.wide { gap: var(--space-4); }
.row.top  { align-items: flex-start; }
.between { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); }
.grow   { flex: 1; min-width: 0; }
.clip   { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mt-1 { margin-top: var(--space-1); } .mt-2 { margin-top: var(--space-2); }
.ml-1 { margin-left: var(--space-1); }
.hairline { flex: 1; height: var(--border); background: var(--line); }
.mt-3 { margin-top: var(--space-3); } .mt-4 { margin-top: var(--space-4); }
.mt-5 { margin-top: var(--space-5); } .mt-6 { margin-top: var(--space-6); }

@media (max-width: 1100px) {
  .g4 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .split { grid-template-columns: 1fr; }
}
@media (max-width: 860px) {
  .g2, .g3 { grid-template-columns: 1fr; }
}

/* ── the app shell ───────────────────────────────────────────────────────── */
.app { display: grid; grid-template-columns: 236px minmax(0, 1fr); min-height: 100vh; }
.side {
  background: var(--sunken); border-right: var(--border) solid var(--line);
  padding: var(--space-3) var(--space-3);
  display: flex; flex-direction: column; gap: var(--space-1);
}
.side .sect {
  font: 600 var(--fs-micro)/1 var(--font-sans); color: var(--muted);
  padding: var(--space-3) var(--space-2) var(--space-1);
  letter-spacing: .02em;
}
.side a {
  display: flex; align-items: center; gap: var(--space-2);
  min-height: 32px; padding: 0 var(--space-2);
  border-radius: var(--radius-sm);
  color: var(--ink2); font-size: 13.5px;
  text-decoration: none;
}
.side a:hover { background: var(--gray-50); text-decoration: none; }
.side a[aria-current="page"] {
  background: var(--raised); color: var(--ink); font-weight: 500;
  box-shadow: var(--shadow); border: var(--border) solid var(--line);
}
.side .ico { width: 16px; text-align: center; color: var(--muted); font-size: 13px; }
.side .foot {
  margin-top: auto; padding: var(--space-3) 0 0 var(--space-2);
  border-top: var(--border) solid var(--line);
  display: flex; gap: var(--space-2); align-items: center;
}

.main { min-width: 0; display: flex; flex-direction: column; }
.top {
  display: flex; align-items: center; gap: var(--space-3);
  /* WRAP, because the header now carries the estate row: brand, five links and a status group do not fit 390px,
     and a header that overflows is how a page starts scrolling sideways. Measured before the change: /answers/
     had no route to a sibling at all, so the row is new and the header had to learn to wrap. */
  flex-wrap: wrap; row-gap: var(--space-2); padding-block: var(--space-2);
  min-height: 56px; padding-inline: var(--space-5);
  border-bottom: var(--border) solid var(--line); background: var(--bg);
}
.top .crumb { display: flex; gap: var(--space-2); align-items: center; font-size: var(--fs-sm); color: var(--muted); }
.top .right { margin-left: auto; display: flex; gap: var(--space-2); align-items: center; }
.search {
  display: flex; align-items: center; gap: var(--space-2);
  min-width: 240px; height: 32px; padding: 0 var(--space-2);
  border: var(--border) solid var(--line2); border-radius: var(--radius-sm);
  color: var(--muted); background: var(--bg); font-size: var(--fs-sm);
}

.body {
  padding: var(--space-5); display: flex; flex-direction: column; gap: var(--space-5);
  min-width: 0;
}
.body > * { min-width: 0; }

.pagehead { display: flex; gap: var(--space-4); align-items: flex-start; flex-wrap: wrap; }
.pagehead .t h1 { font-size: var(--fs-h2); }
.pagehead .t p { margin-top: var(--space-1); color: var(--muted); font-size: 13.5px; max-width: 40em; }
.pagehead .act { margin-left: auto; display: flex; gap: var(--space-2); }

/* two panels that stack: settings (nav + form) and the inbox (list + message) */
@media (max-width: 760px) {
  .setgrid { grid-template-columns: 1fr !important; }
  .setgrid > nav { display: flex; flex-wrap: wrap; gap: var(--space-1); }
  .inboxgrid { grid-template-columns: 1fr !important; }
  .inboxgrid > div:first-child { border-right: 0; border-bottom: var(--border) solid var(--line); }
}

@media (max-width: 860px) {
  .app { grid-template-columns: 1fr; }
  .side { display: none; }
  .body { padding: var(--space-4); }
}
@media (max-width: 700px) {
  .top { padding: 0 var(--space-3); gap: var(--space-2); }
  .top .search { display: none; }
  .top .right { gap: var(--space-1); }
  .pagehead .act { width: 100%; margin-left: 0; }
}

/* ── the reading shell ───────────────────────────────────────────────────── */
/* For a long-form page with a rail: an answer, a reference, a post. Distinct from the marketing
   shell, which is a landing page: this one is a reading column first and everything else second.
   The cap is not a new number. It is the fleet's measured rule (marketing.css:58-66, 97% of 1280,
   87% of 1600, 73% of 1920) carried over, with the rail expressed in em so it scales with the type
   instead of with the viewport.

   The prose column resolves --measure from the PROSE size by construction: a block whose own font
   is 13px mono would resolve the same token narrower and sit ragged beside its neighbour. */
.read {
  /* A reading surface is set in the prose size, not the interface size: 14px is a table's size, and
     16px is what the fleet's own article pages were already using before this port. */
  font-size: var(--fs-prose);
  /* the rail and the gutter are part of the cap, so the PROSE TRACK is exactly --measure and every
     paragraph on the page resolves to one width. Cap without the gutter was 44px narrow, which the
     rendered gate reported as a ragged body on the first run. */
  max-inline-size: calc(var(--measure) + 18em + var(--space-6));
  margin-inline: auto;
  padding: var(--space-7) var(--space-5) var(--space-8);
}
.read p { max-inline-size: var(--measure); }
.read h1 { font-size: var(--fs-h2); line-height: 1.2; }
.read-bar {
  display: flex; align-items: center; gap: var(--space-4);
  padding: var(--space-3) 0;
  border-bottom: var(--border) solid var(--line);
  max-inline-size: calc(var(--measure) + 18em + var(--space-6));
  margin-inline: auto;
}
/* One column at EVERY width. A rail beside the prose gives its paragraphs a narrower track, and the
   rendered gate then reports the body as ragged because prose blocks no longer share a width. The
   aside lands last instead, which is where an ask belongs and where the fleet already put it on a
   phone. A reading page that needs a true sidebar is a different shell, not this one. */
.read-body { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--space-6); }
.read-body > aside { order: 2; }
/* At desk the rail sits beside the article, and every prose block stays in the LEFT cell, so they
   all resolve to one track. Two prose widths on one page is what the rendered gate calls ragged. */
@media (min-width: 900px) {
  .read-body { grid-template-columns: minmax(0, 1fr) 18em; align-items: start; }
  .read-body > aside { order: 0; }
}
/* a directory: entries use the width instead of running down one narrow column */
.read-list {
  display: grid; grid-template-columns: repeat(auto-fit, minmax(18em, 1fr));
  gap: var(--space-5) var(--space-6);
  list-style: none; margin: 0; padding: 0;
}

/* ── the marketing shell ─────────────────────────────────────────────────── */
.mk { max-width: 1080px; margin: 0 auto; padding: 0 var(--space-5); }
/* A reading column caps at 1080 on purpose. A console does not: four cards and a table inside 1080px of a
   1920px window wastes a third of the screen, which reads as a stripe and was measured on three emitted
   dashboards in a row. `.mk.wide` is the console width; the reading width is untouched. */
.mk.wide { max-width: 1440px; }
.mnav {
  display: flex; align-items: center; gap: var(--space-5);
  min-height: var(--space-8);
  border-bottom: var(--border) solid var(--line);
}
.mnav .links { display: flex; gap: var(--space-5); margin-left: var(--space-4); font-size: 13.5px; color: var(--ink2); }
.mnav .links a { display: inline-flex; align-items: center; min-height: 24px; color: var(--ink2); }
.mnav .links a:hover { color: var(--ink); }
.mnav .right { margin-left: auto; display: flex; gap: var(--space-2); align-items: center; }

.hero {
  padding: var(--space-8) 0 var(--space-7);
  display: grid; grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr);
  gap: var(--space-6); align-items: center;
}
.hero h1 { font-size: var(--fs-display); line-height: 1.06; letter-spacing: var(--tracking-hero); }
.hero p { margin-top: var(--space-4); font-size: var(--fs-prose); line-height: 1.55; color: var(--ink2); max-width: 31em; }
.hero .cta { display: flex; gap: var(--space-2); margin-top: var(--space-5); align-items: center; }

.eyebrow {
  font: 500 12.5px/1 var(--font-sans); color: var(--accent);
  margin-bottom: var(--space-3); display: flex; gap: var(--space-2); align-items: center;
}
.section { padding: var(--space-6) 0; border-top: var(--border) solid var(--line); }
.section h2 { font-size: var(--fs-h2); letter-spacing: var(--tracking-tight); }
.lede { margin-top: var(--space-2); color: var(--muted); font-size: var(--fs-prose); max-width: 36em; }
.feat { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-5); margin-top: var(--space-5); }
.feat .ico {
  width: 32px; height: 32px; border-radius: var(--radius-sm);
  background: var(--accent-soft); color: var(--accent);
  display: flex; align-items: center; justify-content: center;
  font-size: var(--fs-prose); margin-bottom: var(--space-3);
}
.feat h3 { margin-bottom: var(--space-1); }
.feat p { color: var(--muted); font-size: var(--fs-sm); line-height: 1.5; }
.mfoot {
  border-top: var(--border) solid var(--line);
  padding: var(--space-5) 0; margin-top: var(--space-6);
  display: flex; gap: var(--space-5); flex-wrap: wrap;
  color: var(--muted); font-size: 12.5px;
}

@media (max-width: 900px) {
  .hero { grid-template-columns: 1fr; padding: var(--space-6) 0; }
  .hero h1 { font-size: var(--fs-h1); }
  .feat { grid-template-columns: 1fr; }
}
@media (max-width: 700px) {
  .mnav { flex-wrap: wrap; gap: var(--space-3); padding: var(--space-2) 0; }
  .mnav .links { order: 3; width: 100%; margin: 0; gap: var(--space-4); font-size: var(--fs-sm); }
  .mnav .right { margin-left: auto; }
}

/* ── the auth shell ──────────────────────────────────────────────────────── */
.auth { min-height: 100vh; display: grid; grid-template-columns: 1fr 1fr; }
.auth .side2 {
  background: var(--sunken); border-right: var(--border) solid var(--line);
  padding: var(--space-6);
  display: flex; flex-direction: column; justify-content: space-between;
}
.auth .form { display: flex; align-items: center; justify-content: center; padding: var(--space-6); }
.auth .box { width: 100%; max-width: 352px; }
.auth h1 { font-size: var(--fs-h2); letter-spacing: var(--tracking-tight); }
.auth .sub { margin-top: var(--space-1); color: var(--muted); font-size: 13.5px; }
.auth form { display: flex; flex-direction: column; gap: var(--space-3); margin-top: var(--space-5); }
@media (max-width: 900px) {
  .auth { grid-template-columns: 1fr; }
  .auth .side2 { display: none; }
}

/* ── full-page states ────────────────────────────────────────────────────── */
.err {
  min-height: 100vh; display: flex; flex-direction: column;
  align-items: center; justify-content: center;
  text-align: center; padding: var(--space-6) var(--space-5);
}
.err .code { font: 600 76px/1 var(--font-sans); letter-spacing: -.04em; color: var(--ink); }
.err p { margin-top: var(--space-3); color: var(--muted); max-width: 30em; }

.overlay {
  background: var(--scrim);
  padding: var(--space-8) var(--space-5);
  display: flex; justify-content: center; align-items: flex-start;
  min-height: 100vh;
}

/* One word, one line: for a label that must not split inside a wrapping row. */
.nowrap { white-space: nowrap; }

/* design-system: page-library v1 | components.css */
/* The page library's components, each with every state.

THE RULE
A component reads tier 3 tokens, falls back to tier 2, and never reads tier 1 or a literal.
If a value is missing, it goes in tokens.css and says so; it does not appear here.

STATES ARE PART OF THE COMPONENT
Default, hover, active, focus-visible, disabled, and where it applies loading and invalid.
Writing a state on the page instead of here is how two buttons end up with two focus rings.
*/

/* ── gradient text ────────────────────────────────────────────────────────── */
/* One phrase in a headline, filled with the theme's ramp. It is a component because the Safari
   fallback is two declarations that are wrong to repeat by hand: without the fill colour the text
   renders transparent-but-painted, which looks like a missing glyph. */
.grad-text {
  background: var(--accent-grad-text, var(--accent));
  -webkit-background-clip: text;
  background-clip: text;
  -webkit-text-fill-color: transparent;
  color: transparent;
}

/* ── button ──────────────────────────────────────────────────────────────── */
.btn, .navbtn {
  display: inline-flex; align-items: center; justify-content: center; gap: var(--space-1);
  min-height: var(--control-h); padding: 0 var(--btn-pad);
  border: var(--border) solid var(--btn-line);
  background: var(--btn-bg); color: var(--ink);
  border-radius: var(--btn-radius);
  font: var(--btn-weight) var(--btn-fs)/1 var(--font-sans);
  text-decoration: none; white-space: nowrap; cursor: pointer;
}
.btn:hover, .navbtn:hover { background: var(--btn-bg-hover); text-decoration: none; }
.btn:active, .navbtn:active { transform: translateY(.5px); }
.btn:disabled, .btn[aria-disabled="true"] {
  opacity: .5; cursor: not-allowed; transform: none;
}
.btn:disabled:hover { background: var(--btn-bg); }

.btn.primary {
  background: var(--accent); border-color: var(--accent);
  color: var(--accent-ink); box-shadow: var(--shadow);
}
.btn.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
.btn.primary:disabled { background: var(--accent); }

.btn.gradient {
  background: var(--accent-grad, var(--accent));
  border-color: transparent; color: var(--accent-grad-ink, var(--accent-ink)); box-shadow: var(--shadow);
}
.btn.gradient:hover { background: var(--accent-grad, var(--accent)); filter: brightness(1.08); }

.btn.ghost { background: transparent; border-color: transparent; color: var(--ink2); }
.btn.ghost:hover { background: var(--sunken); }

.btn.danger { color: var(--alert); border-color: var(--alert-line); background: var(--bg); }
.btn.danger:hover { background: var(--alert-soft); }

/* The ask-size rung (R6, his grade 2026-10-25: "better rounded corners on the buttons"). A 48px
   control carried the small-control radius, so the hero's own primary read boxier than the screen it
   replaced. Radius is a property of CONTROL SIZE here, not of the button component: a console button
   stays at --radius-sm because a 36px box with a 14px corner is a pill, and a 48px ask takes
   --radius-lg so it reads as one object rather than a small button scaled up. Size, not decoration:
   the label also takes the reading step, because 13.5px on a 48px control looks undersized. */
.btn.lg {
  min-height: 48px; padding: 0 var(--space-5);
  border-radius: var(--radius-lg);
  font-size: var(--fs-base);
}

.navbtn.bare { background: transparent; border-color: transparent; color: var(--ink2); }
.navbtn.bare:hover { background: var(--sunken); }

.btn.sm, .navbtn.sm { min-height: var(--control-h-sm); padding: 0 var(--space-2); font-size: 12.5px; }
.btn.block, .navbtn.block { width: 100%; }

/* Loading: the label stays, a spinner replaces the icon, and the width does not jump. The
   button itself carries a slow opacity pulse, so "still working" is legible even where the
   pseudo-element spinner is not painted (and so the reduced-motion contract has something to
   keep alive, instead of freezing the only signal that the app is not hung). */
.btn[aria-busy="true"] { pointer-events: none; animation: busy 1.4s ease-in-out infinite; }
@keyframes busy { 50% { opacity: .74; } }
.btn[aria-busy="true"]::before {
  content: ""; width: 10px; height: 10px; border-radius: 50%;
  border: 2px solid currentColor; border-right-color: transparent;
  animation: spin 700ms linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }

/* ── field ───────────────────────────────────────────────────────────────── */
.field { display: flex; flex-direction: column; gap: var(--space-1); }
/* an inline field sits in a filter bar: the control sizes to its content, the label sits above */
.field.inline select, .field.inline input { min-height: var(--space-6); width: auto; }
.field label { font: 500 var(--fs-sm)/1.3 var(--font-sans); color: var(--ink2); }
/* a bare hint outside a field is still a hint: muted, never ink (R4: the shadcn treatment) */
.hint { font-size: 12px; color: var(--muted); }
.field .hint { font-size: 12px; color: var(--muted); }
.field .error { font-size: 12px; color: var(--alert); display: flex; gap: var(--space-1); align-items: center; }

/* EVERY INPUT THAT TAKES TEXT OR A DATE. `type="date"` was missing from this list, so a date control was the one
   field on a form that took no height, no padding, no border and no font from the sheet: measured 2026-10-28 on a
   consumer's live form at 1280, a 283x21 native widget beside 36px siblings, which the rendered gate reports as a
   WCAG 2.5.8 target-size failure at every width. `time`, `datetime-local`, `month` and `week` are the same control
   family and were missing with it, so they are closed here in one line rather than one per report. A file, range,
   colour or checkbox input is NOT in the family: each paints its own control and takes its height elsewhere. */
/* AND AN INPUT WITH NO type ATTRIBUTE (or an EMPTY one) IS A TEXT INPUT. Measured 2026-10-29 on the receptionist
   console: every field written without the attribute missed this list, so it painted a native white box, 21px tall
   with a black caret, beside 38px dark siblings on a near-black page. `type=""` is Text state by the spec and
   matches neither line, so it is named too; an arbitrary invalid value cannot be enumerated, and it lands in the
   same place only until someone writes one. */
input:not([type]), input[type=""],
input[type="text"], input[type="email"], input[type="password"], input[type="search"],
input[type="number"], input[type="tel"], input[type="url"],
input[type="date"], input[type="time"], input[type="datetime-local"], input[type="month"], input[type="week"],
select, textarea {
  width: 100%;
  min-height: var(--control-h); padding: var(--space-2) var(--space-3);
  border: var(--border) solid var(--field-line);
  border-radius: var(--field-radius);
  background: var(--field-bg); color: var(--ink);
  font: var(--fs-base)/1.4 var(--font-sans);
}
textarea { min-height: 96px; resize: vertical; }
/* the ring from base.css is the focus affordance; a text field also tints its border so
   the caret's context is obvious. No `outline: none` here, because the ring IS the outline. */
input:focus-visible, select:focus-visible, textarea:focus-visible {
  border-color: var(--accent);
}
input:disabled, select:disabled, textarea:disabled {
  background: var(--sunken); color: var(--muted); cursor: not-allowed;
}
input[readonly] { background: var(--sunken); }
input[aria-invalid="true"], select[aria-invalid="true"], textarea[aria-invalid="true"] {
  border-color: var(--alert); box-shadow: 0 0 0 3px var(--alert-soft);
}

/* a checkbox is a control: the target floor applies to it, so it is 24px, not 13 */
.check { display: flex; gap: var(--space-2); align-items: flex-start; font-size: var(--fs-sm); color: var(--ink2); }
/* a checkbox is a touch target wherever it sits: the 24px floor is global, not per-context */
input[type="checkbox"], input[type="radio"] { margin: 0; width: 24px; height: 24px; min-width: 24px; accent-color: var(--accent); }
.check input { margin: 0; flex: none; }
td input[type="checkbox"], th input[type="checkbox"], input[type="radio"] {
  width: 24px; height: 24px; min-width: 24px; flex: none;
  accent-color: var(--accent); margin: 0;
}

/* ── card ────────────────────────────────────────────────────────────────── */
.card {
  background: var(--card-bg);
  border: var(--border) solid var(--card-line);
  border-radius: var(--card-radius);
  box-shadow: var(--shadow);
  min-width: 0;
}
.card-pad { padding: var(--card-pad); }

/* ── badge ───────────────────────────────────────────────────────────────── */
.badge {
  display: inline-flex; align-items: center; gap: var(--space-1);
  height: var(--badge-h); padding: 0 var(--space-2);
  border-radius: var(--badge-radius);
  font: 500 var(--badge-fs)/1 var(--font-sans);
  background: var(--sunken); color: var(--ink2);
  border: var(--border) solid var(--line);
}
.badge.ok     { background: var(--ok-soft);    color: var(--ok);    border-color: var(--ok-line); }
.badge.warn   { background: var(--warn-soft);  color: var(--warn);  border-color: var(--warn-line); }
.badge.alert  { background: var(--alert-soft); color: var(--alert); border-color: var(--alert-line); }
.badge.accent { background: var(--accent-soft); color: var(--accent); border-color: var(--accent-soft); }
.dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; display: inline-block; }

/* A FEATURE chip, and a second component rather than a taller badge (R6, his grade 2026-10-25: the
   hand-built screen's chips "have better spacing"). A badge is a STATUS: 22px because it sits in a
   table row or a panel head beside text that sets its rhythm. A hero's feature chips are read at a
   glance and need air, so this one takes its height from the badge rung plus one space step, and its
   colours are the NEUTRAL pair: a feature is not a state, and --ok/--warn/--alert mean state only. */
.pill {
  display: inline-flex; align-items: center;
  height: calc(var(--badge-h) + var(--space-2));
  padding: 0 var(--space-3);
  border-radius: var(--radius-pill);
  font: 500 var(--fs-sm)/1 var(--font-sans);
  background: var(--sunken); color: var(--ink2);
  border: var(--border) solid var(--line);
}

/* ── banner ──────────────────────────────────────────────────────────────── */
/* A PAGE-LEVEL MESSAGE: the refusal, the note, the confirmation. One ground, one border, one role
   stripe. It says something about the whole page, so it sits in the page's column and not under a
   field. A field's own refusal is `.field .error` and stays there.

   THE ROLE STRIPE IS THE BOUNDARY; THE REST OF THE BORDER IS NOT. Measured on the `zpe` theme,
   2026-11-05: the role lines cannot carry an edge. `--alert-line` reads 1.45:1 against `--raised`,
   `--warn-line` 1.72, `--ok-line` 1.81, and a border is a UI boundary with a 3:1 floor. The role
   colour clears it on its own ground: `--alert` 7.30:1 on `--alert-soft`, `--warn` 8.79, `--ok`
   10.42. So a `var(--space-1)` stripe is the edge, and the hairline stays a hairline. Raising the
   three `--*-line` tokens instead was rejected: `.badge.alert` draws its chip edge with
   `--alert-line`, and a 3:1 chip border makes every badge on the estate heavy.

   OUT OF SCOPE. Written here because a block that grows by accident is two blocks by next quarter:
   - not a toast: no timer, no dismissal, no `position: fixed`. It lives in the document, and it goes
     when the page that drew it goes.
   - not a modal and not a dialog: no backdrop, no focus trap, no `role="dialog"`.
   - not a full-bleed strip that spans a shell. That is a different block, and it needs its own
     admission.
   - no icon set, and no glyph baked in. An author may put one glyph as the first child; the `gap`
     spaces it like any other child.
   - no `role` is set by this sheet. The AUTHOR writes `role="alert"` for a refusal that must
     interrupt, and `role="status"` for a note. A banner that mounts with the page must not announce
     twice, and only the page knows which of the two it drew.
   - no motion. Nothing here transitions or animates. */
.banner {
  display: flex; align-items: flex-start; gap: var(--space-2);
  padding: var(--space-3) var(--space-4);
  border: var(--border) solid var(--line2);
  border-left: var(--space-1) solid var(--accent);
  border-radius: var(--radius-sm);
  background: var(--raised); color: var(--ink);
  font-size: var(--fs-sm); line-height: var(--lh-body);
}
.banner.error { background: var(--alert-soft); border-color: var(--alert-line); border-left-color: var(--alert); }
.banner.warn  { background: var(--warn-soft);  border-color: var(--warn-line);  border-left-color: var(--warn);  }
.banner.ok    { background: var(--ok-soft);    border-color: var(--ok-line);    border-left-color: var(--ok);    }

/* ── table wrapper ───────────────────────────────────────────────────────── */
/* The class was named in the docs, in the component notes and in the prompt that generates pages,
   and it was never implemented. A generated dashboard wrapped its table in `.table-wrap` and the
   document still scrolled sideways at 390px, because a wrapper that does not clip scrolls with the
   page. This is the rule the name promised. */
.table-wrap { overflow-x: auto; max-inline-size: 100%; -webkit-overflow-scrolling: touch; }

/* ── panel row ───────────────────────────────────────────────────────────── */
/* A card's header and footer are the same object: a padded band with one rule. Writing the
   padding on the page seven times is how two cards end up with two header heights. */
.panelrow {
  display: flex; align-items: center; gap: var(--space-2);
  padding: var(--space-4) var(--space-4);
  border-bottom: var(--border) solid var(--line);
}
.panelrow.foot { border-bottom: 0; border-top: var(--border) solid var(--line); }

/* A list row inside a card: icon, text, trailing value, one rule under it. */
.lrow {
  display: flex; align-items: center; gap: var(--space-2);
  padding: var(--space-3) var(--space-4);
  border-bottom: var(--border) solid var(--line);
}
.lrow:last-child { border-bottom: 0; }
.lrow.on { background: var(--accent-soft); }

/* A row separated by a rule ABOVE it, for stacked prose blocks that are not in a card. */
.rowline { padding: var(--space-4) 0; border-top: var(--border) solid var(--line); }

/* ── icon tile ───────────────────────────────────────────────────────────── */
/* The 30px accent square used for a feature's icon. It is a container, so it holds the icon
   at 16px and never a glyph from the text face. */
.ico-tile {
  width: 30px; height: 30px; border-radius: var(--radius);
  background: var(--accent-soft); color: var(--accent);
  display: flex; align-items: center; justify-content: center;
  margin-bottom: var(--space-2);
}

/* ── tab ─────────────────────────────────────────────────────────────────── */
/* One item of a segmented control or a settings rail. aria-current is the only selected
   state; a class called .active would be a second way to say the same thing. */
.tab {
  display: inline-flex; align-items: center;
  min-height: var(--space-6); padding: 0 var(--space-2);
  border-radius: var(--radius-sm);
  font-size: var(--fs-sm); color: var(--ink2);
  background: transparent; border: var(--border) solid transparent;
  text-decoration: none;
}
.tab:hover { background: var(--sunken); text-decoration: none; }
.tab[aria-current="page"], .tab[aria-selected="true"] {
  background: var(--raised); border-color: var(--line);
  color: var(--ink); font-weight: 500; box-shadow: var(--shadow);
}

/* ── wordmark ────────────────────────────────────────────────────────────── */
/* A customer's logo drawn as its name, because a library cannot ship their trademark. */
/* A wordmark is a LINK, so it is a target: 44x14 measured on an emitted page that put it in a nav with
   nothing giving it height. min-block-size makes it legal wherever it sits, rather than asking every page
   to know which container supplies its box. */
.wordmark {
  display: inline-flex; align-items: center;
  min-block-size: var(--space-6);
  font: 600 var(--fs-base)/1 var(--font-sans);
  color: var(--muted); letter-spacing: -.01em;
}

/* ── plan feature list ───────────────────────────────────────────────────── */
/* A priced plan's feature list. The tick is a text character on purpose: an icon font would be
   a second typeface and a second failure mode. */
.plan-desc { margin-top: var(--space-2); min-height: 38px; }
/* A priced plan is a column whose last child is the ask. The ask sits on the floor of the card,
   so three plans with three different feature counts still put their buttons on one line. */
.plan { display: flex; flex-direction: column; }
.plan > .btn.block, .plan > .navbtn.block, .plan > .btn { margin-top: auto; }
.plan > .btn:not(.block), .plan > .navbtn:not(.block) { align-self: flex-start; }
.strong { font-weight: 500; }
.planlist { margin: var(--space-4) 0 0; padding: 0; list-style: none; display: grid; gap: var(--space-2); font-size: var(--fs-sm); }
.planlist li { display: flex; gap: var(--space-2); }
.planlist .tick { color: var(--ok); }

/* ── display number ──────────────────────────────────────────────────────── */
/* A single number used as the subject of a section, not as body copy. */
.display { font: 600 var(--fs-display)/1 var(--font-sans); letter-spacing: var(--tracking-hero); }

/* ── table cells that are controls ───────────────────────────────────────── */
/* A checkbox in a row is a control with a label, so the cell centres it and the hit area is
   the cell, not the 16px box the browser draws. */
.check-cell { display: flex; align-items: center; gap: var(--space-2); }
.check-cell input[type="checkbox"] { accent-color: var(--accent); }

/* ── quote and citation ──────────────────────────────────────────────────── */
/* The quoted figure, and the line that says where it came from. A quote is not a card: it is the
   thing the page exists to deliver, so it gets a rule on the reading edge and nothing else. */
.quote {
  margin: var(--space-4) 0 0;
  padding: var(--space-1) 0 var(--space-1) var(--space-4);
  border-left: 3px solid var(--accent);
  max-inline-size: var(--measure);
}
.cite { display: block; margin-top: var(--space-2); font-size: var(--fs-sm); color: var(--muted); }

/* ── term list ───────────────────────────────────────────────────────────── */
/* A list of named things, each with a paragraph. Not a table: there is nothing to compare across
   columns, and a table would invent an alignment the content does not have. */
.terms { display: grid; gap: var(--space-4); margin: var(--space-4) 0 0; padding: 0; list-style: none; }
.terms > li { display: grid; gap: var(--space-1); }
/* `.label`, not `.term`: `.term` is the terminal component, and a class that means two things is
   the collision this system exists to remove. It rendered the article's terms as black consoles. */
.terms .label { font-weight: 600; }

/* ── meter ───────────────────────────────────────────────────────────────── */
/* A proportion you can see instead of read. The track is the sunken surface, the fill is the
   accent, and neither carries a value: the number beside it does. */
.meter { height: 7px; background: var(--sunken); border-radius: var(--radius-pill); overflow: hidden; }
.meter > i { display: block; height: 100%; background: var(--accent); border-radius: inherit; }

/* ── skeleton ────────────────────────────────────────────────────────────── */
.sk { background: var(--sunken); border-radius: var(--radius-sm); height: 12px; }
.sk.line  { height: 10px; }
.sk.title { height: 26px; }

/* ── avatar, kbd ─────────────────────────────────────────────────────────── */
.avatar {
  width: 32px; height: 32px; border-radius: 50%;
  background: var(--sunken); border: var(--border) solid var(--line);
  display: inline-flex; align-items: center; justify-content: center;
  font: 600 var(--fs-label)/1 var(--font-sans); color: var(--ink2);
}
.avatar.sm { width: 26px; height: 26px; font-size: var(--fs-micro); }
.avatar.xs { width: 22px; height: 22px; font-size: var(--fs-micro); }
/* .on is the avatar in a completed step: the mark carries the state, so the label does not */
.avatar.on { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); }
/* three step states, and they must not be told apart by hue alone: done is filled with a tick,
   current is filled and ringed, upcoming is outlined. */
.avatar.current { box-shadow: 0 0 0 3px var(--accent-soft); }
.avatar.lg { width: 56px; height: 56px; font-size: var(--fs-h3); }
.kbd {
  display: inline-flex; align-items: center; min-height: 20px; padding: 0 var(--space-1);
  border: var(--border) solid var(--line2); border-bottom-width: 2px;
  border-radius: var(--radius-sm); font: 500 var(--fs-micro)/1 var(--font-mono);
  color: var(--muted); background: var(--bg);
}

/* ── tiles ───────────────────────────────────────────────────────────────── */
/* R3 verdict 2026-10-22: shadcn's card tiles, graded. A stat grid that auto-fits and never
   squeezes below a 150px tile; the R2-graded dashboard stance stays the default. */
.tiles { display: grid; gap: var(--gap-card); grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); }

/* ── cover ────────────────────────────────────────────────────────────────────
   A playlist, an album, a file, a project: the thing a person picks from a shelf needs a shape to look at.
   The page supplies the glyph inside; this supplies the square, the tint and the radius. Without it a list
   of media has nothing to be a grid OF, which is why the playlists page came back as a table. */
.cover {
  aspect-ratio: 1;
  border-radius: var(--radius);
  background: var(--accent-soft);
  color: var(--accent);
  display: grid;
  place-items: center;
  overflow: hidden;
}
.cover .icon { inline-size: 38%; block-size: 38%; stroke-width: 1.25; }
.cover.sm { inline-size: var(--space-7); aspect-ratio: 1; }
.tiles > * { min-width: 0; }

/* ── rail ────────────────────────────────────────────────────────────────────────
   The settings surface's nav: a column of icon and label rows with small-cap group labels. It is the app
   shell's `.side` without the shell, so a page that is not a full-height app can still carry the same nav
   language. Shape copied from the desktop app's settings overlay (13rem rail beside its pane); the width is
   the page's, since this is a card in a column rather than a grid track. */
.rail {
  display: flex; flex-direction: column; gap: var(--space-1);
  padding: var(--space-2);
  background: var(--raised); border: var(--border) solid var(--line); border-radius: var(--radius);
}
.rail .sect {
  font: 600 var(--fs-micro)/1 var(--font-sans); color: var(--muted);
  padding: var(--space-3) var(--space-2) var(--space-1); letter-spacing: .02em;
}
.rail a {
  display: flex; align-items: center; gap: var(--space-2);
  min-height: 32px; padding: 0 var(--space-2);
  border-radius: var(--radius-sm); color: var(--ink2); font-size: 13.5px;
  text-decoration: none;
}
.rail a:hover { background: var(--sunken); }
.rail a[aria-current="page"] {
  background: var(--raised); color: var(--ink); font-weight: 500;
  box-shadow: var(--shadow); border: var(--border) solid var(--line);
}
.rail .icon { color: var(--muted); }

/* ── shell ───────────────────────────────────────────────────────────────────────
   A rail and the pane beside it, at the desktop app's settings proportions (13rem = 208px, the rail beside
   its pane), collapsing to one column at the width where a rail stops being a rail. `.app` is this shape with
   a full-height window around it; a page that is not a window needs the shape without the height. */
.shell { display: grid; grid-template-columns: 208px minmax(0, 1fr); gap: var(--space-4); align-items: start; }
@media (max-width: 47.5rem) { .shell { grid-template-columns: minmax(0, 1fr); } }

/* ── thumb ───────────────────────────────────────────────────────────────────────
   A miniature of an arrangement, the way the desktop app's layout picker draws one: rows of live zones as
   filled blocks, and a zone the arrangement leaves empty as a dashed outline rather than a fainter block.
   Colour is derived from the ink, so it is legible on every theme without leaning on the accent. */
.thumb {
  display: flex; flex-direction: column; gap: var(--space-1);
  height: 44px; padding: var(--space-1);
  border: var(--border) solid var(--line); border-radius: var(--radius-sm); background: var(--sunken);
}
.thumb-row { display: flex; gap: var(--space-1); flex: 1; min-height: 0; }
.thumb-cell { flex: 1; min-width: 0; border-radius: var(--radius-sm); background: color-mix(in srgb, var(--ink) 16%, transparent); }
.thumb-cell.rest { background: transparent; border: 1px dashed color-mix(in srgb, var(--ink) 30%, transparent); }

/* ── empty ───────────────────────────────────────────────────────────────── */
/* R2 verdict 2026-10-22: loading defaults to skeletons (.sk); active work is a .busy line that
   names what is running; a loaded-and-idle screen is this pattern, with exactly one action. */
.empty { display: flex; flex-direction: column; align-items: center; text-align: center;
  gap: var(--space-2); padding: var(--space-6) var(--space-4); }
.empty .label { justify-content: center; }
.empty .hint { max-width: 38ch; margin: 0; }
.empty .btn { margin-top: var(--space-2); }

/* ── stat ────────────────────────────────────────────────────────────────── */
/* A stat is a CARD, not a padded column. Measured on two generated dashboards side by side: the same four
   numbers read as a wireframe when they sat directly on the page background and as an instrument panel
   when each sat on its own raised surface with a hairline edge. */
.stat {
  padding: var(--space-4);
  background: var(--raised);
  border: var(--border) solid var(--line);
  border-radius: var(--radius);
}
.stat .k { font-size: 12.5px; color: var(--muted); }
.stat .v {
  font: 600 26px/1.2 var(--font-sans); letter-spacing: var(--tracking-tight);
  margin-top: var(--space-1); font-variant-numeric: tabular-nums;
}
.stat .d { font-size: 12.5px; margin-top: var(--space-1); display: flex; gap: var(--space-1); align-items: center; }
/* A delta says which way the number moved, so it carries the colour of the news. Measured on a generated
   dashboard: "+6 since 09:00" and "Up 8 ms" both rendered in the muted grey, which is a number that refuses
   to say whether it is good. */
.stat .d.ok { color: var(--ok); }
.stat .d.warn { color: var(--warn); }
.stat .d.alert { color: var(--alert); }

/* ── sparkline ─────────────────────────────────────────────────────────────────
   The one thing the generated pages were missing that the eye reads as quality: a number beside its own
   recent shape. The page writes one inline SVG per stat, in a 100x24 viewBox, and this rule supplies the
   box, the weight and the colour, so four of them in a row read as one instrument instead of four
   drawings. Direction is a variant (ok, warn, alert) rather than a literal, because a rising drift and a
   rising listener count are opposite news in the same colour otherwise. */
.spark {
  display: block;
  inline-size: 100%;
  block-size: var(--space-6);
  margin-block-start: var(--space-2);
  overflow: visible;
}
/* One colour drives the line AND its fill, so a variant is one word on the element. The fill is the line's
   own colour at 12% rather than a soft token, because the soft tokens are surfaces and a 12% wash of the
   stroke is what reads as depth under a 1.5px line instead of a second panel. */
.spark { color: var(--accent); }
.spark polyline { fill: none; stroke: currentColor; stroke-width: 1.5; vector-effect: non-scaling-stroke;
  stroke-linecap: round; stroke-linejoin: round; }
.spark .spark-area { fill: currentColor; opacity: .12; stroke: none; }
.spark.ok { color: var(--ok); }
.spark.warn { color: var(--warn); }
.spark.alert { color: var(--alert); }

/* ── table ───────────────────────────────────────────────────────────────── */
table { width: 100%; border-collapse: collapse; font-size: var(--table-fs); }
th {
  text-align: left; font: 600 var(--table-head-fs)/1 var(--font-sans);
  color: var(--ink2); padding: var(--space-2) var(--space-3);
  background: var(--sunken); border-bottom: var(--border) solid var(--line); white-space: nowrap;
}
td { padding: var(--table-cell-pad); border-bottom: var(--border) solid var(--line); vertical-align: middle; }
tbody tr:hover { background: var(--sunken); }
tbody tr:last-child td { border-bottom: 0; }
td.num, th.num {
  text-align: right;
  font-variant-numeric: tabular-nums;
  font-family: var(--font-mono); font-size: 12.5px;
}

/* ── brand mark ──────────────────────────────────────────────────────────── */
.brand { display: flex; align-items: center; gap: var(--space-2); font-weight: 600; font-size: 14.5px; }
.brand .mark {
  width: 24px; height: 24px; border-radius: var(--radius-sm);
  background: var(--accent-grad, var(--accent)); color: var(--accent-grad-ink, var(--accent-ink));
  display: flex; align-items: center; justify-content: center;
  font: 700 var(--fs-label)/1 var(--font-sans);
}

/* ── empty state ─────────────────────────────────────────────────────────── */
.empty {
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  text-align: center; padding: var(--space-7) var(--space-5);
  border: var(--border) dashed var(--line2);
  border-radius: var(--radius); background: var(--sunken);
}
.empty .ico {
  width: 44px; height: 44px; border-radius: var(--space-3);
  background: var(--bg); border: var(--border) solid var(--line);
  display: flex; align-items: center; justify-content: center;
  font-size: var(--fs-h3); margin-bottom: var(--space-3);
}

/* ── terminal ────────────────────────────────────────────────────────────── */
.term {
  background: var(--term-bg); color: var(--term-ink);
  border-radius: var(--radius); border: var(--border) solid var(--term-line);
  overflow: hidden; font: 12.5px/1.65 var(--font-mono);
  box-shadow: var(--shadow-lg);
}
.term .bar {
  display: flex; align-items: center; gap: var(--space-2);
  padding: var(--space-2) var(--space-3); background: var(--term-bar);
  border-bottom: var(--border) solid var(--term-line2);
  font-size: 11.5px; color: var(--term-dim);
}
.term .bar .lights { display: flex; gap: var(--space-1); }
.term .bar i { width: 9px; height: 9px; border-radius: 50%; background: var(--term-lamp); display: block; }
.term pre { margin: 0; padding: var(--space-4) var(--space-4) var(--space-5); overflow-x: auto; white-space: pre; }
.term .p { color: var(--term-ok); } .term .c { color: var(--term-dim); }
.term .y { color: var(--term-warn); } .term .r { color: var(--term-alert); } .term .b { color: var(--term-info); }
.caret { display: inline-block; width: 7px; height: 14px; background: var(--term-ink); vertical-align: -2px; animation: blink 1.1s steps(1) infinite; }
@keyframes blink { 50% { opacity: 0; } }

/* ── command palette ─────────────────────────────────────────────────────── */
.palette {
  width: 100%; max-width: 560px; background: var(--raised);
  border-radius: var(--radius-lg); box-shadow: var(--shadow-modal); overflow: hidden;
}
.palette .in { display: flex; gap: var(--space-2); align-items: center; padding: var(--space-3) var(--space-4); border-bottom: var(--border) solid var(--line); color: var(--muted); }
.palette .in input { border: 0; box-shadow: none; padding: 0; min-height: 24px; font-size: var(--fs-base); }
.palette .grp { font: 600 var(--fs-micro)/1 var(--font-sans); color: var(--muted); padding: var(--space-3) var(--space-4) var(--space-1); }
.palette .it { display: flex; gap: var(--space-2); align-items: center; padding: var(--space-2) var(--space-4); font-size: 13.5px; }
.palette .it[data-on] { background: var(--accent-soft); }
.palette .it .k { margin-left: auto; }

/* ── reduced motion ──────────────────────────────────────────────────────── */
/* ── emphasis and icons ──────────────────────────────────────────────────────────────
   Two additions, and both because the system could be correct and still read as empty: a generated
   page had classes for everything and used four of them.

   .band is the one emphasis surface a page gets, for whatever the page is actually about. It is the
   soft accent tint with a solid accent edge, NOT the accent gradient, because text on a gradient
   cannot be measured. The gradient's lightest stop decides legibility and it is not a colour the
   contract can name, so a band built on it would carry a declaration that cannot fail. Text here is
   --ink, which measures 13.7:1 to 16.1:1 on --accent-soft in all five themes; --accent on the same
   tint is only 2.94:1 in tape and 3.76:1 in amp, which is why no accent-coloured text sits in a band. */
.band {
  position: relative;
  overflow: hidden;
  padding: var(--space-5) var(--space-5) var(--space-5) calc(var(--space-5) + var(--space-2));
  border: var(--border) solid var(--line);
  border-radius: var(--radius-box);
  background: var(--accent-soft);
  color: var(--ink);
}
.band::before {
  content: "";
  position: absolute;
  inset-block: 0;
  inset-inline-start: 0;
  inline-size: calc(var(--space-1) / 2);
  background: var(--accent);
}
.band .band-title { font-size: var(--fs-h3); line-height: 1.25; font-weight: 650; }
.band .band-note { color: var(--ink2); font-size: var(--fs-sm); }

/* .icon is every inline SVG a page carries: one em box, the text's own colour, one stroke weight.
   Rule 2 already asks for inline SVG with currentColor, and that alone still lets a row of eight
   icons read as eight different drawings. This is the shared weight and box that stops it. */
.icon {
  inline-size: 1em;
  block-size: 1em;
  flex: none;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.75;
  stroke-linecap: round;
  stroke-linejoin: round;
  vertical-align: -0.125em;
}
.icon.lg { inline-size: 1.5em; block-size: 1.5em; }
.icon.sm { inline-size: 0.875em; block-size: 0.875em; }

/* Authored as the exception, not the default: motion is written normally and removed here,
   so a new animation is covered by default instead of being forgotten. The caret is the one
   thing that must still read as "live", so it holds at full opacity rather than freezing. */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: var(--dur-0) !important;
    animation-iteration-count: 1 !important;
    transition-duration: var(--dur-0) !important;
    scroll-behavior: auto !important;
  }
  .caret { animation: none; opacity: 1; }
  /* the one exception, and it needs !important to beat the universal rule above: a
     loading indicator that freezes reads as a hung app, which is worse than motion */
  .btn[aria-busy="true"] { animation: busy 1.6s ease-in-out infinite !important; }
  .btn[aria-busy="true"]::before { animation: spin 1.6s linear infinite !important; }
}

/* THE ESTATE ROW: the door and the pages a stranger can open, on every page.
   Why it exists: measured 2026-10-26, /answers/ carried zero links to a sibling app and zero links to /. Four
   public pages, each an island, with the only map on the front page. This is the map, in the header, on all of
   them. It wraps, because five links beside a brand do not fit 390px. */
.estate { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-1) var(--space-3); }
/* 24px floor, because the render gate measured these at 19px tall and a link that small is a miss on a phone.
   The padding is horizontal only: the row already spaces its items vertically. */
.estate a {
  display: inline-flex; align-items: center; min-block-size: 24px; padding-inline: var(--space-1);
  font-size: var(--fs-sm); color: var(--muted); text-decoration: none;
  /* Measured at 390px: without this, "Sign in" and two-word labels split across lines and the header reads as
     noise. A link is a unit; the ROW wraps, the link never does. */
  white-space: nowrap;
}
.estate a:hover { color: var(--ink); text-decoration: none; }
/* The current page is marked by weight and an underline as well as colour: measured on a 390px screenshot, the
   colour alone was indistinguishable from its siblings, which makes "you are here" a claim the eye cannot check. */
.estate a[aria-current="page"] { color: var(--ink); font-weight: 600; text-decoration: underline; text-underline-offset: 3px; }

/* ── ask ─────────────────────────────────────────────────────────────────────
   THE ONE INPUT A PAGE IS ABOUT, as against a field in a form. Full width, 64px, set at reading size,
   with the page's primary control beside it and nothing else on the same level.

   Measured on the launcher (2026-10-27): a 36px search field beside three other 36px controls reads as
   one more control, and a page made of one more control has no subject. The same page with this bar is
   a launcher, and the difference was the height and the type size, not the position. */
.ask { display: flex; gap: var(--space-3); align-items: stretch; }
.ask input {
  flex: 1; min-width: 0; min-height: var(--space-8); padding: 0 var(--space-5);
  font: 400 var(--fs-h3)/1.2 var(--font-sans); color: var(--ink);
  background: var(--raised); border: var(--border) solid var(--line2);
  border-radius: var(--radius-box); box-shadow: var(--shadow-lg);
}
.ask input::placeholder { color: color-mix(in srgb, var(--ink) 58%, transparent); }
.ask .btn,
.ask .navbtn { min-height: var(--space-8); padding: 0 var(--space-6); font-size: var(--fs-prose); }

/* A SECTION WHOSE SUMMARY IS A ROW. `.section` is the disclosure a catalogue is built from: the summary
   is a flex row (label, count, chevron), and a flex row's height is its tallest line, which measured
   22px here against the 24 a control owes. The floor is set once, on the shape, rather than padded onto
   each summary by the page. */
.section > summary { min-height: var(--space-5); align-items: center; }



/* ── the estate bar ───────────────────────────────────────────────────────────────────────────────────────
   ADOPTED from `zpe-nav/public/zpe-bar.css` on 2026-10-28, by the owner's call that the bar belongs to the design
   system rather than to a worker's private taste. This block is that file's chrome, translated to this system's
   roles and measures: every `--zpe-*` reads a token, and the bar's own numbers (52px, 11px at 0.2em, the 68rem
   line, the reserve) live in tokens.css as `--bar-*`. The class names are the ones `zpe-bar.js` emits, because the
   component was adopted and not renamed: no node in the estate changes, and there is one copy of these rules.

   OUT OF SCOPE, and this list is what makes the entry admissible: the registry panel, its search, its groups, its
   counts and its lifecycle marks stay in `zpe-nav/public/zpe-bar.css`. That is an app's data surface, not a nav
   component. What this block draws is the bar: its box, its row, its items, the account corner, the fade variant
   the apex uses, the empty host's reserve, the no-script row, and the phone rule that keeps one row. */
.zpe-nav {
  /* The bar's own family, not the page's. Measured on /vault on 2026-10-28: a page whose body is a mono stack
   * inherited through to the items, so the one bar read as monospace on one surface and sans on the rest. The
   * estate's family is the bar's; a page keeps its own type below. */
  font-family: var(--font-sans);
  position: sticky;
  top: 0;
  z-index: 60;
  display: flex;
  /* NOWRAP, measured on /pool 2026-10-28. A page whose container is narrower than the bar's own line pushed the
   * account corner onto a second row and the bar rendered 100px tall instead of 52 while still looking plausible.
   * A bar that can wrap cannot be adopted by an app screen, and every one of those is narrower than the front door. */
  flex-wrap: nowrap;
  align-items: center;
  gap: 0.5rem 1.5rem;
  /* Full-bleed, because that is what the estate's own bars already are, and measured on a 68rem page the row's
     ends sat at the window edge while the content column stopped 20rem in. The padding holds the row to a
     68rem line on a wide screen and falls back to the gutter when the window is narrower than that. */
  /* THE TARGET SETS THE BAR'S HEIGHT, NOT AN 11px TYPE LINE. Until 2026-10-31 the items were 24px tall and the
     0.85rem here is what turned a 24px target into a 52px bar. The target is --bar-item-h (44) now, so the
     padding is what is left of --bar-h above that row and its 1px rule: 3.5px, and the bar stays 52. The row's
     text sits where it did (3.5 + 44/2 = 25.5px from the top, against 13.6 + 24/2 = 25.6px before). */
  padding: calc((var(--bar-h) - var(--bar-item-h) - var(--border)) / 2) max(var(--bar-gutter), (100% - 68rem) / 2);
  background: color-mix(in srgb, var(--bg) 88%, transparent);
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
  border-bottom: 1px solid transparent;
  transition: border-color var(--dur-2) ease;
}

/* The hairline is for a bar over prose. A page whose own chrome fades into a starfield gets none: the apex's
 * header has no border in any state, and a 1px line over the field is the seam he named on 2026-10-28. */
.zpe-nav-stick[data-stuck='true'],
.zpe-nav-stick.zpe-nav-open {
  border-bottom-color: var(--line);
}

body .zpe-nav .zpe-nav-mark {
  display: inline-flex;
  align-items: center;
  min-height: 24px;
  color: var(--ink);
  border: 0;
  text-decoration: none;
}

.zpe-nav > *,
.zpe-nav-items > * {
  flex-shrink: 0;
  white-space: nowrap;
}

.zpe-nav-items {
  display: flex;
  /* One story only: the row cannot wrap anywhere. Below 34rem the section links leave the bar and reappear in the
   * panel's sections, which is why the bar is a single line at every width. The breakpoint moved from 30rem on
   * 2026-10-28: measured at 500px with a 24-character account name, the corner was squeezed to 24px of ellipsis
   * because the four links were still in the row. At 34rem the links leave first and the name keeps its room. */
  flex-wrap: nowrap;
  align-items: center;
  gap: 0.25rem 1.35rem;
  font-size: var(--bar-fs);
  letter-spacing: 0.2em;
  text-transform: uppercase;
  /* THE BAR'S OWN GREY, NOT THE PAGE'S --muted. On https://zpe.vc/ 2026-10-31 the page carries no `data-theme`,
     so --muted is the light palette's #6b7280 while the page paints #050507: the items read rgb(107,114,128) at
     4.21:1 on #050507 and 4.34:1 on black, under the 4.5 an 11px uppercase row has to clear. --bar-item-ink
     reads 6.29:1 and 6.48:1 on those grounds, so the bar has its own grey instead of one per theme. It is a PAIR
     since 2026-11-01: #8a8f9b on a dark ground, and #646c7a when the page declares `data-surface="light"` on
     <html>, because the single value read 3.23:1 on #ffffff (/p/audit) and 3.00:1 on #f8f6f1 (/invoices). The
     corner below carries the same token. */
  color: var(--bar-item-ink);
}

body .zpe-nav .zpe-nav-item {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  /* A THUMB'S TARGET, NOT A POINTER'S. Measured on the live estate 2026-10-31: APPS 53x24, SIGN IN 53x24,
     BUILD 44x24 at 1280 and at 390. 24 is the pointer floor exactly and half of what a thumb needs, so the
     item's box is --bar-item-h (44) and the bar's padding gives up the 20px it used to add (see .zpe-nav). */
  min-height: var(--bar-item-h);
  padding: 0;
  border: 0;
  background: none;
  font: inherit;
  letter-spacing: inherit;
  text-transform: inherit;
  color: inherit;
  text-decoration: none;
  cursor: pointer;
  white-space: nowrap;
}

body .zpe-nav .zpe-nav-item:hover {
  color: var(--ink);
}

body .zpe-nav .zpe-nav-item[aria-current='page'] {
  color: var(--ink);
  font-weight: 500;
  text-decoration: underline;
  text-underline-offset: 4px;
  text-decoration-thickness: 1px;
}

.zpe-nav-caret {
  width: 9px;
  height: 6px;
  transition: transform var(--dur-2) ease;
}

.zpe-nav-apps[aria-expanded='true'] .zpe-nav-caret {
  transform: rotate(180deg);
}

.zpe-nav-account {
  /* The corner is the only child allowed to give ground. Measured 2026-10-28 by a cold review with a 24-character
   * name: with every child at flex-shrink 0 the bar's content ran 613px wide in a 500px window, so the account
   * affordance sat entirely off-screen in the narrow containers the nowrap rule exists for. It truncates instead. */
  flex-shrink: 1;
  min-width: 0;
  margin-left: auto;
  display: inline-flex;
  align-items: center;
  gap: 1rem;
  min-height: 24px;
  font-size: var(--bar-fs);
  letter-spacing: 0.16em;
  text-transform: uppercase;
  /* THE SAME ROW, THE SAME TOKEN: SIGN IN is not an item, it is this corner, and it measured rgb(107,114,128)
     on the live apex beside them. See the .zpe-nav-items rule for the pair and the `data-surface` hook. */
  color: var(--bar-item-ink);
  white-space: nowrap;
}

/* A 24px floor on the link itself, not just on the row around it: measured by another repo's render gate on
 * 2026-10-28, the corner link computed 49x16 (an inline anchor inside a 24px flex row), so the only account
 * affordance in the bar was a 16px tap target on every surface that adopted it. */
body .zpe-nav .zpe-nav-account a {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  display: inline-flex;
  align-items: center;
  min-height: 24px;
  border: 0;
  color: inherit;
  text-decoration: none;
}

body .zpe-nav .zpe-nav-account a:hover,
body .zpe-nav .zpe-nav-account a.zpe-nav-you {
  color: var(--ink);
}

/* THE CORNER'S LINK IS THE SAME TARGET AS AN ITEM. With the items at 44 and this link at 24, the one control a
   signed-out reader presses measured 53x24 on the live apex, so the row was only half fixed. Scoped to the site
   bar on purpose: the app skin's header is 2.5rem tall and its own link sits under its own contract, and a 44px
   corner there would push it. */
body .zpe-nav:not(.zpe-nav-app) .zpe-nav-account a {
  min-height: var(--bar-item-h);
}

/* ── the fade variant: for a page whose own header waits for the hero to go ─────────────────────
   The apex is that page. Measured 2026-10-28: the row at `opacity: 0` at scrollY 0 and 400, `opacity: 1` at 900 on
   a 900px viewport, on a 500ms transition with a 4px rise. The four page links still arrive on that same 500ms and
   4px, and the bar only takes a ground once it is over content, because a strip of blur over a starfield is a seam.

   WHAT ARRIVES IS FOUR LINKS, NOT THE ROW. The owner's decision, 2026-10-31: "Keep SIGN IN and APPS visible from
   the top, let the four content links fade." Until that decision the whole row slept at scroll 0, so a stranger's
   first frame on the apex carried one exit (the hero's own SEE THE WORK), no way to sign in and no way to reach the
   app index; on a phone it carried neither APPS nor SIGN IN.

   WHICH ITEMS ARE "the four content links" IS THE SCRIPT'S ANSWER, NOT THIS SHEET'S. `zpe-bar.js` marks BUILD,
   WRITING, PRIVACY and CONTACT with `zpe-nav-sectionlink`, and the mark, the app index and the account corner do
   not carry it. Marked items rather than "every item that is not the app index", because a sixth item added to the
   row should not join this set without somebody deciding that it does. The name is already shipped in that script,
   so this sheet reads it and does not rename it: a rename would need a bar deploy in lockstep with a publish.

   THE ROW, THE CORNER AND THE MARK CARRY NO FADE AT ALL. They do not change state, so there is no opacity to set
   and no transition to run; the four links keep the 500ms and the 4px the row used to carry, and take no pointer
   event while they are invisible, so a press at scroll 0 lands on the row and never on a link nobody can see. */
/* The wake line: an invisible marker at 0.8 of the viewport, observed rather than measured on every scroll. */
.zpe-nav-line {
  position: absolute;
  top: 80vh;
  left: 0;
  width: 1px;
  height: 1px;
  pointer-events: none;
  opacity: 0;
}

.zpe-nav-fade {
  position: fixed;
  inset-inline: 0;
  top: 0;
  background: transparent;
  border-bottom-color: transparent;
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
  transition: background 500ms ease, border-color 500ms ease;
}

.zpe-nav-fade .zpe-nav-sectionlink {
  opacity: 0;
  transform: translateY(-4px);
  pointer-events: none;
  transition: opacity 500ms ease, transform 500ms ease;
}

/* Awake, the fade bar stays a piece of the sky: transparent, unblurred, no rule. A wash and a blur were here
 * first and they are the same seam in a softer form, which on the apex is a band across the stars. What arrives
 * is the four page links, and nothing else. */
.zpe-nav-fade[data-awake='true'],
.zpe-nav-fade.zpe-nav-open {
  background: transparent;
  border-bottom-color: transparent;
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
}

/* Awake at the line, and while the panel is open. The panel hangs off a row that is always drawn, so opening it
 * wakes nothing but these four links; the rule is here because a page can open the panel at scroll 0, where the
 * bar is still asleep. */
.zpe-nav-fade:is([data-awake='true'], .zpe-nav-open) .zpe-nav-sectionlink {
  opacity: 1;
  transform: none;
  pointer-events: auto;
}

/* THE PLACEHOLDER'S HEIGHT, BEFORE THE SCRIPT LANDS. The bar is filled after parse, so a page that reserved
   nothing jumped by the bar's own height when it arrived. Two adopters worked this out independently and carried
   the rule page-local (/services/ and, through zpe-kit's frame, /privacy/remove), which is the drift this sheet
   exists to end: a reserve is a property of the bar, not of each page that hosts it. Measured 2026-10-28 at 1440:
   3.4rem against a 52px bar, so the content below moves by 2px rather than 52. */
[data-zpe-bar] { min-height: 3.4rem; }

/* NO JAVASCRIPT, and no bar: a page that carries the estate row inside <noscript> still gets a usable one.
   Measured 2026-10-28 with the script blocked: /blog kept 54px of reserved emptiness and its only navigation was
   the footer, and /services/ had none at all. The row below is styled by this same sheet, so a page must link it
   in its own head rather than let the script inject it. */
.zpe-noscript {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.3rem 1.2rem;
  min-height: 3.4rem;
  padding: 0.6rem var(--bar-gutter);
  border-bottom: 1px solid var(--line);
  font-family: var(--font-sans);
  font-size: var(--bar-fs);
  letter-spacing: 0.18em;
  text-transform: uppercase;
}

body .zpe-noscript a {
  display: inline-flex;
  align-items: center;
  min-height: 24px;
  color: var(--muted);
  border: 0;
  text-decoration: none;
}

body .zpe-noscript a:hover {
  color: var(--ink);
}

/* The four section links, shown inside the panel only where the bar hides them. */
.zpe-nav-sections {
  display: none;
  flex-wrap: wrap;
  gap: 0.4rem 1.2rem;
  margin: 0 0 1.1rem;
  padding: 0 0 0.9rem;
  border-bottom: 1px solid var(--line);
  font-size: var(--bar-fs);
  letter-spacing: 0.18em;
  text-transform: uppercase;
}

.zpe-nav-sections a {
  display: inline-flex;
  align-items: center;
  /* THE SAME FOUR LABELS ON A PHONE, so the same two defects. Measured on https://zpe.vc/ at 390 on 2026-10-31:
     the panel's section strip carried Build, Writing, Privacy and Contact at 11px, rgb(107,114,128) on the
     panel's rgb(10,10,15), which is 4.09:1, and at 24px tall. They leave the bar below 34rem and land here, so
     the bar's target and its grey are this row's too. */
  min-height: var(--bar-item-h);
  color: var(--bar-item-ink);
  border: 0;
  text-decoration: none;
}

.zpe-nav-sections a:hover,
.zpe-nav-sections a[aria-current='page'] {
  color: var(--ink);
}

@media (max-width: 34rem) {
  .zpe-nav-sections {
    display: flex;
  }
}


/* A phone keeps one row: a mark, Apps and the account. Measured 2026-10-28 at 390px, five items beside the
   mark and Sign in wrapped the bar to 133px, which is 16% of an 844px screen held sticky over every scroll. The
   four links move into the panel, so nothing is lost, and the panel is one press away. The line moved from 30rem
   to 34rem on 2026-10-28: at 500px the links were still in the bar and squeezed the account corner to 24px. */
@media (max-width: 34rem) {
  .zpe-nav .zpe-nav-item:not(.zpe-nav-apps) {
    display: none;
  }
}

@media (max-width: 34rem) {
  .zpe-nav {
    gap: 0.35rem 1rem;
    padding-block: var(--space-2);
  }
  /* A 44px target does not fit in a 41px phone bar, and 8px of padding above and below a 44px row would carry
     the bar to 61 on a 390x844 screen. The site bar gives the padding up instead: the item's own box is the air,
     and the text sits 22px from the top instead of 20. The app skin keeps the padding above. */
  body .zpe-nav:not(.zpe-nav-app) {
    padding-block: 0;
  }
  .zpe-nav-account {
    margin-left: 0;
  }
  .zpe-nav-groups {
    grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
  }
}

/* ==========================================================================
   station: a finding the reader can press. SPEC row 22, admitted 2026-10-31.

   IN SCOPE: a headline, one line of what the finding says, one press, a stage that shows it happening,
   one note after the press, and an optional corrected state.
   OUT OF SCOPE, and none of these merges here later:
     - charts, plots or trends: that is the chart panel, not this
     - multi-step wizards, forms or inputs of any kind
     - any number typed by hand: a station renders values its CALLER measured, and a type without a
       measured field does not render at all
     - external assets of any kind (no image, no font, no script from a CDN)
     - the verdict row: a score stays a score, the station is the explanation under it
   MOTION: scaleX and opacity only (row 12). Reduced motion prints the end state.
   ========================================================================== */

.station {
  display: block;
  background: var(--card-bg);
  border: var(--border) solid var(--line);
  border-radius: var(--radius-box);
  padding: var(--space-3);
}
.station + .station { margin-top: var(--space-2); }
.station-head { font-size: var(--fs-h3); margin: 0 0 var(--space-1); }
.station-says { color: var(--ink2); margin: 0 0 var(--space-2); max-width: var(--measure); }
.station-says b { color: var(--ink); }
.station-act { display: flex; flex-wrap: wrap; gap: var(--space-1); }
.station-stage { margin-top: var(--space-2); }
.station-clock {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  font-weight: 600;
  color: var(--alert);
}
.station-bar {
  height: 12px;
  border-radius: var(--radius-pill);
  background: var(--sunken);
  overflow: hidden;
  margin: var(--space-1) 0;
}
.station-bar i {
  display: block;
  height: 100%;
  width: 100%;
  background: var(--alert);
  transform: scaleX(0);
  transform-origin: left center;
}
.station-skel div {
  height: 10px;
  border-radius: var(--radius-sm);
  background: var(--sunken);
  margin: var(--space-1) 0;
}
.station-skel .a { width: 62%; }
.station-skel .b { width: 88%; }
.station-skel .c { width: 74%; }
.station-code {
  font-family: var(--font-mono);
  font-size: var(--fs-sm);
  background: var(--sunken);
  border: var(--border) solid var(--line);
  border-radius: var(--radius-sm);
  padding: var(--space-2);
  overflow: auto;
  margin: var(--space-1) 0 0;
}
.station-code mark { background: var(--alert-soft); color: var(--alert); padding-inline: var(--space-1); border-radius: var(--radius-sm); }
.station-code mark.fixed { background: var(--ok-soft); color: var(--ok); }
.station-note { font-size: var(--fs-sm); color: var(--ink2); margin: var(--space-1) 0 0; }
.station[hidden], .station-stage[hidden], .station-note[hidden] { display: none; }

@media (prefers-reduced-motion: no-preference) {
  .station-bar i { transition: transform 400ms linear; }
}
@media (prefers-reduced-motion: reduce) {
  .station-bar i { transition: none; }
}


/* ── the band grid ────────────────────────────────────────────────────────────────────────────────────────
   A page that argues fills the window: a label column, a reading column, and an aside that carries the same
   section's sharpest line. Three tracks at the console width, one below 1000px.

   WHY NOT A CENTRED COLUMN. `.mk` centres a 1080 container and every prose block inside it is capped by
   `--measure`, so on a 1280 window a reading page was a 476px column at x=124 with 556px of nothing to its
   right, which a reader reports as "the text is shifted to the left" (measured 2026-11-01 on zpe.vc/platform).
   Centring the column instead leaves 368px of nothing on each side, which he rejected in the same words he
   uses for a page that does not fill: "it should fill the page like every enterprise website does".
   ──────────────────────────────────────────────────────────────────────────────────────────────────────── */
.band2 { display: grid; grid-template-columns: 200px minmax(0, 62ch) minmax(160px, 1fr); gap: var(--space-5) var(--space-7); align-items: start; }
.band2 > .lead { position: sticky; top: 88px; }
.band2 .m-prose, .band2 .prose { max-width: 100%; }
.span2 { grid-column: 2 / -1; }
.span3 { grid-column: 1 / -1; }
@media (max-width: 1000px) {
  .band2 { grid-template-columns: 1fr; gap: var(--space-4); }
  .band2 > .lead { position: static; }
  .span2, .span3 { grid-column: auto; }
}

/* ── a figure ─────────────────────────────────────────────────────────────────────────────────────────────
   THE MARGIN RESET IS THE POINT. `base.css` resets `h1` to `h4` and `p`; it does not reset `figure`, so a
   `<figure>` inherits the browser's `1em 40px` and lands 40px inside the text edge it is meant to share
   (measured 2026-11-01 on zpe.vc/platform: body at x=124, figure at x=165). The width is fluid and capped by
   the column the figure sits in, so a diagram authored at 640 units renders at that column's own width.
   ──────────────────────────────────────────────────────────────────────────────────────────────────────── */
.fig { margin: var(--space-6) 0 0; }
.fig > svg, .fig > img, .fig > picture > img { display: block; width: 100%; height: auto; max-width: 100%; }
.fig figcaption { margin-top: var(--space-3); font-size: var(--fs-sm); color: var(--muted); max-width: 52em; }

/* ── motion, earned and skippable ─────────────────────────────────────────────────────────────────────────
   A section rises once as it enters, and nothing else moves. Three conditions: the reader has not asked for
   less, the browser can run a scroll timeline, and the element carries `.rise`. It degrades to a static page
   everywhere else, so it is safe on a page that carries the business's copy: no JavaScript, only opacity and
   12px of Y.

   THE RANGE MUST COMPLETE WITHIN THE ENTRY. A range reaching into the `cover` phase never finishes for the
   LAST block on a page, because the document cannot scroll far enough to move it 30% past full view: measured
   2026-11-02 on zpe.vc/platform, the closing section was left at opacity 0.78 for every reader who reached the
   bottom. `entry 0% entry 80%` completes before a block is fully in view, so a short last section still lands.
   ──────────────────────────────────────────────────────────────────────────────────────────────────────── */
@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: view()) {
    .rise { animation: rise-in linear both; animation-timeline: view(); animation-range: entry 0% entry 80%; }
    @keyframes rise-in { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
  }
}

/* Space Grotesk, the face the door wears, self-hosted so no page needs a font CDN. SIL OFL, one variable
   file covering 400 to 700 in the latin subset, 22 KB. Only fetched by a theme that names it. */
@font-face {
  font-family: "Space Grotesk";
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url("fonts/space-grotesk-latin.woff2") format("woff2");
  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;
}

/* ══ zpe: the door's own language, measured from the live app on 2026-10-26 ══════════════════════════
   The door is React and Tailwind, and its taste is this scope: the ground #050507, ink #e0e7ff, the violet
   accent, Space Grotesk. On 2026-10-28 the door stopped carrying a palette of its own and reads these roles
   by name (zpe-vc/src/app/globals.css), so a value written here is the value the door renders.

   The accent is the one value that moved. The door's text accent had been its own electric purple #6b21a8,
   which measures 2.34:1 on the void; --accent is #a78bfa at 7.48:1, and that is what the door renders now.
   #6b21a8 survives only as the first stop of --accent-grad, because a gradient is a surface and its label
   sits on --accent-grad-ink, never on --accent-ink: the flat accent is pale and takes a dark ink (#0b0713,
   7.32:1), the gradient is dark and takes white. Read as one ink, the mark's glyph measured 2.28:1 on the
   violet stop and 3.52:1 on the blue, which on the live 24px mark is a smudge. White on those two stops
   measures 8.72:1 and 5.67:1.

   It lives in its own file rather than in tokens.css so a product palette does not grow the canonical file
   every time one appears, and so a page opts in with one extra <link>. */
:root[data-theme="zpe"] {
  /* THE UA MUST BE TOLD, or every part it paints itself stays light on a near-black page: a date popup, a
     scrollbar, an autofilled field. Measured 2026-10-29 on this theme: an untyped input reported
     background rgb(255,255,255) against a surface that is rgb(5,5,7), and the popup and scrollbar followed it.
     Costs no token and fixes all three. */
  color-scheme: dark;
  --bg:#050507;
  --sunken:#030304;
  --raised:#0a0a0f;
  --ink:#e0e7ff;
  --ink2:#b6bfe0;
  --muted:#8b93b8;
  --line:#1c1c2a;
  /* THE CONTROL BOUNDARY CLEARS 3:1, and the card rung does not (yet). A border is a UI boundary, not
     decoration, and the floor for it is 3:1: tape moved #333333 -> #606060 (1.57 -> 3.16) for the same
     reason. Measured on this theme 2026-11-05: #2a2a3d read 1.45:1 on --bg and 1.41:1 on --raised, so a
     button separated only by its border read as text with a faint edge. #60607e is 3.37:1 on --bg and
     3.27:1 on --raised, and it keeps the violet cast rather than landing a neutral grey on it.
     `--line` is NOT raised with it: it draws dividers and card edges, and a 3:1 hairline between table
     rows would be a rule, not a divider. A card on this theme still separates at 1.21:1, which is the
     second finding and not this one. */
  --line2:#60607e;

  --accent:#a78bfa;
  --accent-hover:#c4b5fd;
  --accent-ink:#0b0713;
  --accent-soft:#141430;
  --accent-grad:linear-gradient(96deg,#6b21a8,#3b5bdb);
  /* the gradient's own label ink: white, 8.72:1 on the violet stop and 5.67:1 on the blue.
     --accent-ink (#0b0713) stays the ink for the FLAT accent, where it reads 7.32:1. */
  --accent-grad-ink:#ffffff;

  --ok:#6ee7a0;      --ok-soft:#12251a;      --ok-line:#1f4430;
  --warn:#f0b46b;    --warn-soft:#2a1f10;    --warn-line:#4a3618;
  --alert:#f08c8c;   --alert-soft:#2a1416;   --alert-line:#4a2222;

  --scrim:rgba(0,0,0,.62);
  --shadow:0 1px 2px rgba(0,0,0,.4),0 1px 3px rgba(0,0,0,.3);
  --shadow-lg:0 12px 32px rgba(0,0,0,.5),0 2px 8px rgba(0,0,0,.35);
  --ring:0 0 0 2px var(--bg),0 0 0 4px var(--accent);

  --font-sans:"Space Grotesk", ui-sans-serif, system-ui, -apple-system, sans-serif;

  /* @contrast --ink on --bg 7
     @contrast --ink on --raised 7
     @contrast --ink2 on --bg 4.5
     @contrast --muted on --bg 4.5
     @contrast --muted on --raised 4.5
     @contrast --accent on --bg 4.5
     @contrast --accent-ink on --accent 4.5
     @contrast --ok on --bg 4.5
     @contrast --warn on --bg 4.5
     @contrast --alert on --bg 4.5
     @contrast --ink on --accent-soft 4.5
    */
}

/* ══ print: the same page as a document ══════════════════════════════════════════════════════════════
   A dark theme prints white-on-white, because a driver drops background graphics by default and the ink
   tokens are near-white. Measured on zpe.vc/answers, 3 November 2026: the print medium kept rgb(224,231,255)
   body text on an unprinted ground, so the sheet arrived blank.

   THE FIX BELONGS IN THE THEME, NOT IN EACH PAGE. The theme owns colour, so print is another mode of it:
   the same roles re-pointed at paper values, once, for every page that opts into this theme. A page-level
   override cannot do this honestly anyway, because the fleet's design check refuses colour literals outside
   the token layer, and this file IS the token layer.

   The roles keep their meaning on paper: --accent stays the one accent (it is ink here, because a violet on
   white at small sizes is a contrast risk), and the alert role stays the risk colour in position, not hue. */
@media print {
  /* DOUBLE :root IS DELIBERATE. The theme's own block is :root[data-theme="zpe"], and this one has to beat it
     whatever a later sheet imports or re-states: measured on zpe.vc/answers, an equal-specificity print block
     in this same file went inert, and the sheet printed dark. One extra pseudo-class settles it without an
     !important, which would outrank a page that legitimately wants its own print rule. */
  :root:root[data-theme="zpe"] {
    color-scheme: light;
    --bg: #ffffff;
    --sunken: #ffffff;
    --raised: #ffffff;
    --ink: #111111;
    --ink2: #333333;
    --muted: #5a5a5a;
    --line: #d4d4d4;
    --line2: #9a9a9a;
    --accent: #111111;
    --accent-hover: #000000;
    --accent-ink: #111111;
    /* paper ink, not the gradient's white: a driver drops background graphics by default, so the mark's
       ground does not print while its glyph does. Read as white, the glyph vanishes on the paper. */
    --accent-grad-ink: #111111;
    --accent-soft: #f2f2f2;
    --alert: #8a2b2b;
    --warn: #6b4b12;
    --ok: #1d4d2b;
    --ring: #111111;
    --shadow: none;
    --shadow-lg: none;
  }
}

/* docs/worker/page.css, the ONLY stylesheet in this repo a person writes.
 *
 * tokens.css, base.css, components.css and themes/zpe.css are read from
 * ~/Projects/design/packages/design-system at generation time (tools/docs.mjs) and concatenated ahead
 * of this file, so the page carries the same palette, the same type rungs and the same components as
 * every other page on the estate. A colour literal in this file would be a second palette:
 * `python3 ~/Projects/design/design_check.py --tokens <design>/tokens.css docs/worker/page.css`
 * refuses one, and the build refuses when the design package is absent rather than falling back to a
 * page with a look of its own.
 *
 * Everything below is LAYOUT this page adds: where the counts sit, how a verb is chipped, how an app's
 * head and its table meet. No component is redefined, the rules that would land on a class the
 * library owns are written as a compound selector so they cannot escape this page.
 */

/* ── the head: a title, the counts in one line, and the one action ──────────
   The first version put four stat tiles, a 300px block explaining the lifecycle words, a
   marketing block and a filter row above the first card, and the first operation sat 1,789px
   down a 19,181px page: two screens of preamble before the reference a reader came for
   (measured 2026-10-31). The counts are a sentence now, the legend is one line, and the
   reference starts after about 500px. */
.docs-head { display: grid; gap: var(--space-3); }
.docs-head .hint code { font-family: var(--font-mono); color: var(--ink2); }
.docs-summary {
  display: block; margin-top: var(--space-2);
  font-size: var(--fs-sm); color: var(--muted); font-variant-numeric: tabular-nums;
}
.docs-legend { margin: var(--space-1) 0 0; font-size: var(--fs-sm); color: var(--muted); }
.docs-legend .badge { margin-inline-end: var(--space-1); }
.docs-headbar {
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-3) var(--space-4);
  margin-top: var(--space-2);
}
.docs-headbar .hint { margin: 0; max-width: var(--measure); }

/* A verb is not a status, so it is not a badge: a read is neutral, a write takes the accent. */
.verb {
  display: inline-block; min-width: 3.25rem;
  padding: var(--space-1) var(--space-2);
  border: var(--border) solid var(--line); border-radius: var(--radius-sm);
  background: var(--sunken); color: var(--ink); border-color: var(--line2);
  font: 600 var(--fs-micro)/1 var(--font-mono);
  text-align: center; letter-spacing: var(--tracking-tight);
}
.verb.write { background: var(--accent-soft); border-color: var(--accent-soft); color: var(--accent); }

/* ── the state legend ────────────────────────────────────────────────────── */

/* ── the walk-through block, and the provenance the page ends on ──────────── */
.docs-provenance { margin-top: var(--space-4); }
.docs-provenance code { font-family: var(--font-mono); font-size: var(--fs-micro); color: var(--ink2); }
.docs-foot { margin: var(--space-5) 0 var(--space-6); font-size: var(--fs-sm); color: var(--muted); }

/* ── the side navigation ─────────────────────────────────────────────────────
   21 apps and 173 operations make this a page a reader SCROLLS rather than reads top to
   bottom, and the row of pills above the cards stops saying where anything is as soon as
   the first card passes it. Every docs product a developer has used puts the tree on the
   left and keeps it there (GitBook's shell, Scalar's reference); this is that shape built
   from the tokens the rest of the estate uses, not a second palette.

   THE GRID IS DECLARED ON A COMPOUND SELECTOR. `.main` is the library's own class and
   `display: flex` on it is what every other page on the zone was built against, so this
   page's two-column shell is `main.docs-layout` and the library's rule is untouched.

   `top: 0` RATHER THAN THE HEADER'S HEIGHT. The header is not sticky on this page (measured:
   `.top` in the library carries no `position`), so it scrolls away and the tree has the
   viewport to itself. The first version of this rule cleared the header with `--bar-h`, and
   `--bar-h` is not in the copy of tokens.css this worker ships, it is one of the tokens the
   design sheet drift added, so the height clamp computed to `none` and the tree ran 1040px
   tall inside an 819px viewport instead of scrolling inside itself. A rule that depends on a
   token the page does not have is a rule that has already silently stopped applying. */
.main.docs-layout {
  display: grid;
  /* THE RAIL NARROWS BEFORE IT STACKS. It was a fixed 14rem, and the page went one-column
     below 62rem, so at any window narrower than 992 CSS px the tree moved to the top of the
     page instead of staying beside it (measured on the live host at 990px: `grid-template-
     columns: 942px`, rail `static`, body starting 240px below the rail). A reader on a
     laptop window or at browser zoom never saw the rail on the left at all. The rail is
     fluid now, and the two-column shell survives down to 768px. */
  grid-template-columns: clamp(10.5rem, 18vw, 14rem) minmax(0, 1fr);
  align-content: start;
  align-items: start;
  column-gap: var(--space-6);
}
.docs-body { min-width: 0; }
.docs-side {
  position: sticky;
  top: 0;
  align-self: start;
  max-height: calc(100vh - var(--space-6));
  overflow-y: auto;
  overscroll-behavior: contain;
  display: grid;
  /* ONE COLUMN, AND IT MAY SHRINK. A grid's implicit column sizes to max-content, so the
     horizontal chip strips added for the phone pushed the whole document to 1,914px wide on a
     390px screen (measured). minmax(0, 1fr) is what lets the strip scroll inside itself. */
  grid-template-columns: minmax(0, 1fr);
  gap: var(--space-5);
  padding: var(--space-3) var(--space-4) var(--space-6) 0;
  border-right: var(--border) solid var(--line);
}
.docs-side nav { display: grid; gap: var(--space-1); min-width: 0; }

/* ── the filter, in the rail ─────────────────────────────────────────────────
   One control with two effects, which is the point: the field narrows the tree AND the cards, so
   a reader who types "paste" sees one row and one card rather than a rail that still lists 21
   apps beside a body that lists one. Escape clears it, because a search you cannot clear without
   selecting the text is a trap on a phone. */
.docs-side-filter { display: grid; gap: var(--space-2); }
.docs-side-filter input[type="search"] { min-height: var(--control-h); }
.docs-side-filter .check { display: flex; align-items: center; gap: var(--space-2); font-size: var(--fs-sm); color: var(--muted); }
.docs-side-filter input[type="checkbox"] { accent-color: var(--accent); }
.docs-count-line { margin: 0; font-size: var(--fs-micro); color: var(--muted); font-variant-numeric: tabular-nums; }

/* the word that kept an app out of the published document, on the row itself */
.docs-side .w {
  margin-inline-start: var(--space-1);
  padding: 0 var(--space-1);
  border: var(--border) solid var(--line);
  border-radius: var(--badge-radius);
  font: 500 var(--fs-micro)/1.4 var(--font-mono);
  color: var(--muted);
}
.docs-side-label {
  margin: 0 0 var(--space-2);
  font-size: var(--fs-micro);
  text-transform: uppercase;
  color: var(--muted);
}
.docs-side a {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-2);
  min-height: var(--control-h-sm);
  margin-inline-start: calc(var(--space-2) * -1);
  padding: var(--space-1) var(--space-2);
  border-radius: var(--btn-radius);
  color: var(--ink2);
  text-decoration: none;
  font-size: var(--fs-sm);
}
.docs-side a:hover { color: var(--ink); background: var(--accent-soft); }
/* the current section, marked by the script at the foot of the page: a filled row and a
   rule down its left edge, which is legible without relying on the accent alone */
.docs-side a[aria-current] {
  color: var(--ink);
  background: var(--accent-soft);
  box-shadow: inset 2px 0 0 var(--accent);
}
.docs-side .n {
  font: 500 var(--fs-micro)/1 var(--font-mono);
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}

/* ONE COLUMN UNDER 48rem, and that is the last rung rather than the first: at 768px the rail
   is 10.5rem and the reference still has ~570px for a table that already scrolls inside its
   own box. Below that a rail plus a table is not a layout, it is two columns of clipped text. */
@media (max-width: 48rem) {
  .main.docs-layout { grid-template-columns: minmax(0, 1fr); }
  .docs-side {
    position: static;
    max-height: none;
    overflow: visible;
    gap: var(--space-3);
    padding: 0 0 var(--space-3);
    border-right: none;
    border-bottom: var(--border) solid var(--line);
  }
  /* THE TREE IS A STRIP ON A PHONE. A capped 240px block of links above the reference spends two
     thirds of the first screen on navigation; a horizontal row of 28px chips costs one line and a
     thumb can scroll it. Measured at 390x844 before/after in DEPLOY.md. */
  .docs-side-apps nav,
  .docs-side-machine nav {
    display: flex;
    flex-wrap: nowrap;
    gap: var(--space-2);
    overflow-x: auto;
    padding-bottom: var(--space-1);
    scrollbar-width: none;
  }
  .docs-side a { white-space: nowrap; }
  .docs-side-filter { gap: var(--space-1); }
  /* TWO BLOCKS THAT COST A PHONE SCREEN AND SAY NOTHING NEW THERE. The machine reads are the
     same three links the head carries, and the hint under the button is five lines of provenance
     at 390; both are chrome on a surface that is 38 screens long already. */
  .docs-side-machine,
  .docs-headbar .hint { display: none; }
}

/* ── a reference is scanned, not read, so its rows are tighter than the library's default
   68px: the path and the summary share a row, and the vertical padding drops to one rung.
   Measured after the change at 48px per row, which is 3,400px off the page over 173 rows. */
.doc-app .ops td { padding-block: var(--space-1); vertical-align: top; }
.doc-app .ops th { padding-block: var(--space-2); }
.doc-app .ops .params { margin-top: 0; }

/* ── the index's table of apps ───────────────────────────────────────────────
   The index is a list, not a wall of cards: one row per app, the mount it answers at, how many
   operations it declares and how finished it is. The name is the link, and its box is held to the
   24px floor so a three-letter name is still a target a thumb can hit: `tv`, `uw` and `vc`
   measured 16.3x28 on the live page (2026-11-02) because `inline-flex` sizes to its content.
   THE FLOOR IS A LITERAL, NOT A SPACING TOKEN. The nearest token is 24px on the desktop palette
   and 18px in the phone density block, so a token here would drop below the floor at exactly the
   width where a thumb is the only pointer. WCAG 2.2 SC 2.5.8 asks for one axis at 24px; the
   height already comes from `--control-h-sm` (28 desktop, 24 phone). */
.apps { width: 100%; }
.apps th { text-align: left; }
.apps .app-cell a { text-decoration: none; display: inline-flex; align-items: center; min-height: var(--control-h-sm); min-inline-size: 24px; }
.apps .app-cell code { font-family: var(--font-mono); color: var(--ink); }
.apps .mount-cell code { font-family: var(--font-mono); color: var(--muted); }
.apps tr:hover { background: var(--accent-soft); }

/* ── one app's page: where you are, and what is next ────────────────────────── */
.crumbs {
  display: flex; align-items: center; gap: var(--space-2);
  margin: 0; font-size: var(--fs-sm); color: var(--muted);
}
.crumbs a { color: var(--muted); }
.crumbs [aria-hidden] { color: var(--line); }
.app-pager { display: flex; justify-content: space-between; gap: var(--space-4); margin-top: var(--space-5); }
.pager-link {
  display: inline-flex; flex-direction: column; gap: var(--space-1);
  min-height: var(--control-h); text-decoration: none; color: var(--ink2);
}
.pager-link .dir { font-size: var(--fs-micro); text-transform: uppercase; color: var(--muted); }
.pager-link.next { text-align: right; }
.pager-link:hover { color: var(--ink); }
.rail-home { color: inherit; text-decoration: none; }
.rail-home:hover { color: var(--ink); }

/* ── one operation, as a disclosure ─────────────────────────────────────────
   A row per operation was a list of addresses: method, path, id, summary. A reference has to say
   what the call takes and what it answers, so each operation is a <details> whose summary line is
   the scan (verb, path, title, id) and whose body is the documentation: parameters, body fields,
   responses, and an example call and answer generated from the schema. Collapsed by default,
   because 38 operations each open would be the wall the single page was. */
.op {
  background: var(--card-bg); border: var(--border) solid var(--card-line); border-radius: var(--card-radius);
  margin: 0 0 var(--space-2); overflow: hidden;
}
.op summary {
  display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-2) var(--space-3);
  padding: var(--space-2) var(--space-3); cursor: pointer; list-style: none;
}
.op summary::-webkit-details-marker { display: none; }
.op summary:hover { background: var(--accent-soft); }
.op .op-path { font-family: var(--font-mono); font-size: var(--fs-sm); color: var(--ink); }
.op .op-sum { color: var(--ink2); font-size: var(--fs-sm); }
.op .op-id { margin-inline-start: auto; font: 500 var(--fs-micro)/1 var(--font-mono); color: var(--muted); }
.op[open] summary { background: var(--accent-soft); border-bottom: var(--border) solid var(--line); }
.op-body { padding: var(--space-3) var(--space-3) var(--space-4); display: grid; gap: var(--space-3); }
.op-body h4 { margin: 0; font-size: var(--fs-label); text-transform: uppercase; color: var(--muted); }
.op-body h4 .muted { text-transform: none; letter-spacing: 0; }
.op-none { margin: 0; color: var(--muted); font-size: var(--fs-sm); }
.op-desc, .op-use { margin: 0; color: var(--ink2); }
.props { width: 100%; font-size: var(--fs-sm); }
.props th { text-align: left; color: var(--muted); font-size: var(--fs-micro); text-transform: uppercase; }
.props td { vertical-align: top; }
.props td code { font-family: var(--font-mono); color: var(--ink2); }

/* ── the versions of one contract ────────────────────────────────────────── */
.versions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); margin-top: var(--space-2); }
.versions .docs-side-label { margin: 0; }
.versions .pill.is-active { border-color: var(--accent); color: var(--ink); }

/* ── matches from the other apps ───────────────────────────────────────────
   The field narrows the page you are on. Without this block it would say nothing about the other
   20 apps, and "where is that operation" is the question a reference exists to answer. */
.search-results { border-top: var(--border) solid var(--line); padding-top: var(--space-3); }
.search-list { margin: var(--space-2) 0 0; padding: 0; list-style: none; display: grid; gap: var(--space-1); }
.search-list a { display: block; padding: var(--space-1) var(--space-2); margin-inline-start: calc(var(--space-2) * -1); border-radius: var(--btn-radius); text-decoration: none; min-height: var(--control-h-sm); }
.search-list a:hover { background: var(--accent-soft); }
.search-list code { font-family: var(--font-mono); font-size: var(--fs-sm); color: var(--ink); }
