/* GENERATED FILE, DO NOT EDIT BY HAND. Source: design/tokens/*.json (the tokens) and
   design/theme-rules.css (the rules that are not tokens). Regenerate with
   `node scripts/build-design-tokens.cjs`; `--check` refuses a hand edit to this file. */
/* =====================================================================
   Bespoke Woodcraft Studio — project-wide theme system
   TWO themes: ONE light (`linen-ink`) and ONE dark (`midnight-oak`).
   P606 (2026-08-12) collapsed ten themes to two and the founder picked both
   by name: "We need to have one light theme and one dark theme, and then we
   have it so that the person can choose either on the page or they can do
   system." LATER THE SAME NIGHT he changed the light pick: "I changed my
   mind, I want the linen theme instead of the current blue theme" — so the
   ten-theme era's Linen palette returns as THE light theme, replacing
   porcelain-blue. The ids are internal; the account menu says Light / Dark
   / System and never shows an id.

   NO BROWN, ANYWHERE (founder, P606 session): "I hate brown. We never,
   ever use brown. It looks old and dingy. We are a modern, AI-driven, hip,
   and cool website." Linen's SURFACES are warm paper neutrals — the
   founder's own named pick, not a drift back toward brown — while its
   accent, header and semantic families stay on the cool slate/steel axis,
   and the one mustard token the old Linen carried (--warn #b5862f) stays
   retuned to the P606 amber. Any OTHER warm token added here has to
   justify itself.

   Each theme sets a CANONICAL palette; the alias layer maps that palette
   onto BOTH token vocabularies used across the suite (cream/walnut/gold
   AND surface/accent/text), so one theme recolours every page.
   `:root[data-theme]` (0,2,0) outranks each page's own `:root` (0,1,0),
   so themes win without touching per-page CSS.
   Soft/fill tokens (accent-soft, ok-soft, danger-soft, warn-soft) stay LIGHT
   in both themes — they're light chips/notices paired with dark text.

   SYSTEM MODE lives in the RESOLVED attribute, not in the CSS: the pre-paint
   bootstrap in every page's <head> reads `bws:theme` and, for "system" (or an
   absent/retired value), resolves prefers-color-scheme to ONE OF THE TWO IDS
   and stamps that. So page CSS only ever targets [data-theme="midnight-oak"]
   for dark — one hook, no per-page media-query duplication.
   ===================================================================== */
/* INTER, SELF-HOSTED (AP-13, re-architecture programme, card P2449). This block replaces the Google
   Fonts @import that stood here: a check on a machine that could not reach Google measured a fallback
   font 60.3 pixels off over twenty-five real band strings (4.33 percent at 13 pixels regular), so every
   picture and band measurement was wrong by that much. Now the font the checks render is the font the
   product ships. The font itself does not change: Inter stays. Fourteen files under
   shared/vendor/fonts/inter/ (seven unicode subsets by two styles; each is a variable font that serves
   every weight of its style, so the two weight ranges below cover what the old import asked for:
   400 to 800 upright, 400 to 600 italic). The unicode-range on each face is the one Google served, so a
   page downloads only the subsets its text uses. font-display:swap is kept from the old import.
   tests/math-audit/fonts-self-hosted.audit.cjs fails if any stylesheet or page loads a font from
   another origin, and its journey twin measures a string in the page against the shipped latin file. */
@font-face{ font-family:'Inter'; font-style:normal; font-weight:400 800; font-display:swap;
  src:url('/shared/vendor/fonts/inter/inter-cyrillic-ext-normal.woff2') format('woff2');
  unicode-range:U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; }
@font-face{ font-family:'Inter'; font-style:italic; font-weight:400 600; font-display:swap;
  src:url('/shared/vendor/fonts/inter/inter-cyrillic-ext-italic.woff2') format('woff2');
  unicode-range:U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; }
@font-face{ font-family:'Inter'; font-style:normal; font-weight:400 800; font-display:swap;
  src:url('/shared/vendor/fonts/inter/inter-cyrillic-normal.woff2') format('woff2');
  unicode-range:U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; }
@font-face{ font-family:'Inter'; font-style:italic; font-weight:400 600; font-display:swap;
  src:url('/shared/vendor/fonts/inter/inter-cyrillic-italic.woff2') format('woff2');
  unicode-range:U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; }
@font-face{ font-family:'Inter'; font-style:normal; font-weight:400 800; font-display:swap;
  src:url('/shared/vendor/fonts/inter/inter-greek-ext-normal.woff2') format('woff2');
  unicode-range:U+1F00-1FFF; }
@font-face{ font-family:'Inter'; font-style:italic; font-weight:400 600; font-display:swap;
  src:url('/shared/vendor/fonts/inter/inter-greek-ext-italic.woff2') format('woff2');
  unicode-range:U+1F00-1FFF; }
@font-face{ font-family:'Inter'; font-style:normal; font-weight:400 800; font-display:swap;
  src:url('/shared/vendor/fonts/inter/inter-greek-normal.woff2') format('woff2');
  unicode-range:U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF; }
@font-face{ font-family:'Inter'; font-style:italic; font-weight:400 600; font-display:swap;
  src:url('/shared/vendor/fonts/inter/inter-greek-italic.woff2') format('woff2');
  unicode-range:U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF; }
@font-face{ font-family:'Inter'; font-style:normal; font-weight:400 800; font-display:swap;
  src:url('/shared/vendor/fonts/inter/inter-vietnamese-normal.woff2') format('woff2');
  unicode-range:U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; }
@font-face{ font-family:'Inter'; font-style:italic; font-weight:400 600; font-display:swap;
  src:url('/shared/vendor/fonts/inter/inter-vietnamese-italic.woff2') format('woff2');
  unicode-range:U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; }
@font-face{ font-family:'Inter'; font-style:normal; font-weight:400 800; font-display:swap;
  src:url('/shared/vendor/fonts/inter/inter-latin-ext-normal.woff2') format('woff2');
  unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; }
@font-face{ font-family:'Inter'; font-style:italic; font-weight:400 600; font-display:swap;
  src:url('/shared/vendor/fonts/inter/inter-latin-ext-italic.woff2') format('woff2');
  unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; }
@font-face{ font-family:'Inter'; font-style:normal; font-weight:400 800; font-display:swap;
  src:url('/shared/vendor/fonts/inter/inter-latin-normal.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; }
@font-face{ font-family:'Inter'; font-style:italic; font-weight:400 600; font-display:swap;
  src:url('/shared/vendor/fonts/inter/inter-latin-italic.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; }

/* ONE font, site-wide (founder mandate 2026-06-26; grand-master pick = Inter). Codified here so
   every page that loads theme.css inherits it — no per-page font edits. `:root body` (0,1,1)
   outranks each page's own `body{}` (0,0,1) regardless of source order or whether a theme is
   active, and `:root .serif` (0,2,0) repoints every old Playfair display heading to Inter at a
   heavy weight, so hierarchy comes from WEIGHT within one family (Refactoring UI), not a 2nd font.
   Inter is drawn for screens (tall x-height, open apertures, tabular figures for the dimension
   tools). The Playfair <link> is removed from the few pages that still load it. */
:root{
  --font:"Inter","Inter var",-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,"Helvetica Neue",Arial,sans-serif;
  --font-num:"Inter",system-ui,sans-serif;
  --pd-cursor:#075c50;
  --pd-antigravity:#67368f;
  --pd-codex:#185e94;
  --pd-badge-ink:#ffffff;
  /* THE FLOOR CONTRACT (AP-01, re-architecture programme, card P2449). The two viewport numbers every
     page is measured against, declared ONCE here and nowhere else: the founder's 1280 by 720 floor
     and the product's own 1280 by 600 survive height (claude-evidence/27-adaptive-ui-proof.md
     section 2). scripts/build-design-tokens.cjs picks them up from this plain :root block into
     shared/design-tokens.json, and tests/math-audit/floor-contract.audit.cjs reads that file, so no
     page needs its own copy. */
  --floor-viewport-w:1280px;
  --floor-viewport-h:720px;
  --survive-viewport-h:600px;
  /* The five Designer shell sizes, moved here verbatim from cabinet-designer.html's page-local :root
     (names unchanged, 26 lines of the Designer read them; H-04 never renames them, F3 of its read). The
     -h names are published over these by syncBandHeights as an inline style, which outranks this
     declaration, so no computed value moves. */
  --cd-rail-w:48px;
  --cd-topband-h:var(--cd-topband-base-h);
  --cd-bottomband-h:var(--cd-bottomband-base-h);
  --cd-topband-base-h:calc(48px + var(--roomy-grow));
  --cd-bottomband-base-h:calc(44px + var(--roomy-grow));
  /* THE OPTIMIZER'S AND THE PLANNER'S BAR HEIGHTS (H-04 step 1, re-architecture programme, card P2449; ruling
     H4.1 made this file H-04's source). The height each page reads BEFORE it has measured its own bars: a
     first-paint fallback and nothing else. Each page keeps measuring and publishing its own value (the
     planner already does, as --topbar-h and --bars-h; the optimizer publishes nothing until H-04 step 2),
     and a published value is an inline style, which outranks these declarations, so no computed value moves.
     No page reads these names yet. Added, never renamed: the five names above stay as they are.
     Each number is the height a real browser measured at the 1280 by 720 floor, at Standard density, in both
     themes (the shared bar and the three bars below it, taken with a throwaway probe on 2026-10-03):
         optimizer: shared .topbar 67px, .opt-toolbar 58px, .tabs 49px
         planner:   .topbar 67px, .view-tabs 56px, #ctrl-bar 53px, the three stacked 176px
     The planner's old first-paint literals were 56px and 130px, 11px and 46px short of those. The planner's
     stack is a sum of its three bars, derived here and never typed a second time. */
  --optimizer-topbar-h:67px;
  --optimizer-toolbar-h:58px;
  --optimizer-tabs-h:49px;
  --planner-topbar-h:67px;
  --planner-viewtabs-h:56px;
  --planner-ctrlbar-h:53px;
  --planner-bars-h:calc(var(--planner-topbar-h) + var(--planner-viewtabs-h) + var(--planner-ctrlbar-h));
  /* THE DENSITY SCALE (H-03 step 1, re-architecture programme, card P2449; claude-evidence/27-adaptive-ui-
     architecture.md section 3). ONE integer, --density-step, and four names derived from it on the 4 pixel
     interval Material's density formula and Excalidraw's large-display steps both use:
         height = base + 4px * step
     The step is -1 at Compact, 0 at Standard and +1 at Roomy. It is set by the root attribute
     data-density (rules in design/theme-rules.css, two blocks, Compact and Roomy); with no attribute, or
     any value other than those two, it stays 0, so Standard is where the product already is, named.
     A component reads --ctl-h, --pad-y, --icon and --rail-btn by inheritance and nothing else: it never
     reads the attribute and never names a viewport. Nothing reads them yet (H-03 steps 3 and 4 and the
     pages that adopt them come later), so no page repaints.
         name         Compact  Standard  Roomy   Standard is
         --ctl-h        30px     34px     38px    the Designer band's control rhythm (page-layout.md section 4)
         --pad-y         8px     12px     16px    the --space-1, --space-2 and --space-3 rungs, so every step lands on the scale
         --icon         16px     16px     20px    it only goes up; a 14px icon reads as a smudge
         --rail-btn     32px     36px     40px    .cd-rail-btn's own 36px
     Density never touches the spacing scale above, a colour, a font family, a type size or anything inside
     the drawing. Compact stays above the 24 by 24 pixel target floor with margin (30px). */
  --density-step:0;
  --ctl-h:calc(34px + 4px * var(--density-step));
  --pad-y:calc(12px + 4px * var(--density-step));
  --icon:calc(16px + 4px * max(var(--density-step), 0));
  --rail-btn:calc(36px + 4px * var(--density-step));
  /* THE ROOMY STEP FOR THE SHELL (H-11, re-architecture programme, card P2449; his FD549 A of 2026-10-11, "Yes: that Roomy look is
     approved for large monitors"). A large monitor gives the drawing the extra room and the toolbars grow ONE step, and
     nothing else moves: no panel width changes, no control appears or goes away, no type size or drawing scale changes.
     Two names carry the sizes, each derived from --density-step and each unchanged at Compact and Standard, so the product
     at the floor and at Standard is exactly what it was (the three shell sizes at Standard stay 48, 44 and 36 pixels):
         --roomy-grow      0px at Compact and Standard, 4px at Roomy. The Designer's top band (48 to 52) and bottom band
                           (44 to 48) add it to the number they already had, through the two base names above.
         --shell-rail-btn  36px at Compact and Standard, 40px at Roomy: the Designer's rail buttons.
     The buttons in the bands read --ctl-h (38px at Roomy) as a min-height, in the Designer's shell rules,
     under a rule that names Roomy, because a min-height of zero would cancel the larger one some controls already carry.
     Compact never shrinks the shell (the max() holds the step at zero from below): only Roomy was approved, and a real
     laptop window at 1280 by 720 is under 680 pixels of page, which derives Compact. --cd-topband-h and --cd-bottomband-h
     only seed the first paint from the base names; the Designer publishes the MEASURED height over them as an inline style,
     as before. */
  --roomy-grow:calc(4px * max(var(--density-step), 0));
  --shell-rail-btn:calc(36px + var(--roomy-grow));
}
:root body{ font-family:var(--font); }
:root .serif{ font-family:var(--font); font-weight:800; letter-spacing:-.015em; }
:root .num,:root [data-num]{ font-variant-numeric:tabular-nums lining-nums; }

/* THE DENSITY ATTRIBUTE (H-03 step 1, re-architecture programme, card P2449). `data-density` on the root
   element takes three values in plain words: compact, standard and roomy. It resolves ONE integer,
   --density-step, and the four size names declared with it above (--ctl-h, --pad-y, --icon, --rail-btn,
   in design/tokens/layout.json) derive from that integer, so nothing else here needs to know the value.
   Standard is the plain :root default of 0, which is the product as it already stands: with no attribute,
   with data-density="standard", and with any value this file does not name, the step stays 0 and all 176
   existing token names compute exactly as they did before. There is deliberately no rule for standard
   (a rule that restated the default would be a second copy of it). The two blocks below are (0,2,0) and
   outrank the plain :root (0,1,0), so they win without depending on source order. Density is chrome only:
   no colour, no font family, no type size, nothing inside the drawing. The pre-paint bootstrap that stamps
   the attribute before first paint on every page is H-03 step 3 and is not here yet, so no page sets it. */
:root[data-density="compact"]{ --density-step:-1; }
:root[data-density="roomy"]{ --density-step:1; }

/* [hidden] MEANS HIDDEN, ON EVERY PAGE (BUG-2026-09-03-04). The attribute's only backing is the
   browser's own user-agent rule, and ANY author `display` on the same element beats it: on
   account-settings.html `.btn{display:inline-flex}` painted a highlighted Renew subscription
   button that was rendered `hidden` and meant only for a customer who had already cancelled.
   index.html, materials.html and company-standards.html each met the same fault and each carried
   the fix alone; this is the one copy, loaded by every page that loads this file. `!important`
   so no page rule can lose to it by accident. A rule that MEANS to show a [hidden] element (a
   print-only block, a table row a filter reveals) says so with a more specific selector and its
   own !important, which is what the existing ones already do. */
[hidden]{ display:none !important; }

/* ---------- LIGHT — "Linen" (`linen-ink`), the founder's pick ----------
   Warm linen-paper surfaces under cool slate ink, with a calm steel-blue accent.
   This is the ten-theme era's Linen, restored on the founder's 2026-08-12 order
   ("I want the linen theme instead of the current blue theme"), replacing
   porcelain-blue — with ONE correction kept from P606:
   --warn was #b5862f, a mustard-gold that read as the old brown suite; it stays
   the P606-retuned amber. It is a FILL/BORDER token — warning TEXT goes through
   --warn-text/--warn-ink, which are unchanged and still AA-pinned. */
:root[data-theme="linen-ink"]{
  --pd-cursor:#075c50;
  --pd-antigravity:#67368f;
  --pd-codex:#185e94;
  --pd-badge-ink:#ffffff;
  --bg:#f5f3ee;
  --surface:#ffffff;
  --surface-2:#ebe8e0;
  --surface-hi:#f0ede4;
  --text:#20242b;
  --text-muted:#565d66;
  --text-faint:#616871;
  /* --border-strong (P2067): a dedicated, higher-contrast border token for controls
     whose border alone must clear 3:1 against --surface (WCAG 1.4.11) — --border itself
     was never chosen for that job and stays 1.56:1/1.63:1 against --surface in Linen/
     Midnight Oak, which is why retuning --border directly is out of scope (it would move
     every one of its ~1359 other consumers). Value is the SAME as --text-faint's own
     value in each block (5.63:1 Linen / 5.26:1 Midnight Oak against --surface,
     comfortably past the floor, already vetted, and the more subtle of the two candidate
     tokens — --text-muted clears with even more headroom but reads visually heavier as a
     border), copied here as a resolved literal rather than `var(--text-faint)`: the fix
     round after this piece first shipped found that scripts/build-design-tokens.cjs only
     resolves var() for the --elev-* layer, so a live alias shipped as the unresolved
     STRING "var(--text-faint)" into shared/design-tokens.json, dev-dashboard-design-
     system.js and the portable pack's tokens.css, instead of a usable colour. No new hue
     (FD166) — this is the palette's existing --text-faint value under a second name, kept
     in sync by hand (both live at the same literal in the same four blocks) rather than
     by a var() the generators cannot see through. */
  --line:#e3e0d8;
  --border:#d2cfc5;
  --border-strong:#616871;
  /* same value as --text-faint; a resolved literal so the token generators and the portable pack carry it */
  --accent:#5c7c93;
  --accent-deep:#3f5a6b;
  --accent-soft:#cfe0eb;
  --header-bg:#20242b;
  --header-bg-2:#2c323b;
  --header-bg-3:#171a20;
  --header-line:#3a414b;
  --header-text:#f5f3ee;
  --ok:#5a8a6a;
  --ok-soft:#e4efe8;
  --warn:#c0720f;
  --warn-soft:#f7f0da;
  --danger:#b1503f;
  --danger-soft:#f3e1dc;
  /* P85-3: dashboard identity hues (Dev Dashboard "who/model" chips) get their OWN
     tokens instead of reusing --danger (Fable) / --accent-deep (Opus), so retuning
     --danger for an error state can never shift an identity colour, and vice versa. */
  --identity-fable:#b1503f;
  --identity-opus:#3f5a6b;
  /* P1306: two more identity hues, for the StackDesign team page's desk rings, under the
     same rule. Values are copies of --warn and --accent in each theme, so nothing on
     screen changes - but retuning a warning or accent state can no longer repaint a desk. */
  --identity-desk-amber:#c0720f;
  --identity-desk-steel:#5c7c93;
  /* FD532 A (card P3013): the two product chips in the owner's account list. Indigo for CabDesign,
     plum for StackDesign, each a lightly tinted fill with a matching outline and a darker ink of
     the same hue for the words. Ink on fill clears 4.5:1 and the outline clears 3:1 on --surface
     and --surface-2 in both themes (tests/fd532-brand-chip-colors.test.cjs). Own tokens, so
     retuning an accent or a status hue can never repaint a product chip. */
  --brand-cab-border:#4f5ba6;
  --brand-cab-fill:#e6e8f4;
  --brand-cab-ink:#2f3a78;
  --brand-stack-border:#a14a86;
  --brand-stack-fill:#f3e4ee;
  --brand-stack-ink:#6e2b58;
  --shadow-c:0 1px 2px rgba(32,36,43,.06),0 8px 24px rgba(32,36,43,.08);
  --shadow-lg-c:0 2px 6px rgba(32,36,43,.10),0 18px 44px rgba(32,36,43,.15);
}

/* ---------- DARK — "Midnight Oak" (`midnight-oak`), the founder's pick ----------
   Surfaces and text flip; the soft/fill tokens stay light (they are light chips
   paired with dark ink). Despite the name this palette is cool blue-slate with a
   cyan accent — no wood tone in it, which is why it survived the collapse. */
:root[data-theme="midnight-oak"]{
  --pd-cursor:#75dfcb;
  --pd-antigravity:#cba4ee;
  --pd-codex:#8dc6fa;
  --pd-badge-ink:#11161c;
  --bg:#11161c;
  --surface:#1a212a;
  --surface-2:#232c37;
  --surface-hi:#2c3744;
  --text:#e6edf3;
  --text-muted:#a3b0bd;
  --text-faint:#8994a1;
  /* See the linen-ink block's comment above for --border-strong (P2067). */
  --line:#283340;
  --border:#374453;
  --border-strong:#8994a1;
  /* same value as --text-faint; a resolved literal so the token generators and the portable pack carry it */
  --accent:#4aa3c0;
  --accent-deep:#74c4dd;
  --accent-soft:#bfe0ec;
  --header-bg:#0b1016;
  --header-bg-2:#161e27;
  --header-bg-3:#070b0f;
  --header-line:#283340;
  --header-text:#e6edf3;
  /* --warn was #d8a657 — one hex step off the retired walnut/studio-cream gold
     (#d4a056) and the most brown-reading token left in the suite. Retuned to a
     brighter amber on the orange side. Fill/border only; warning TEXT on dark goes
     through the --warn-text override below, which is unchanged and AA-pinned. */
  --ok:#5fb08a;
  --ok-soft:#e2efe8;
  --warn:#e8a03c;
  --warn-soft:#f7eed5;
  --danger:#dd7a6a;
  --danger-soft:#f3e0db;
  --identity-fable:#dd7a6a;
  --identity-opus:#74c4dd;
  --identity-desk-amber:#e8a03c;
  --identity-desk-steel:#4aa3c0;
  /* FD532 A (card P3013): the two product chips in the owner's account list. Indigo for CabDesign,
     plum for StackDesign, each a lightly tinted fill with a matching outline and a darker ink of
     the same hue for the words. Ink on fill clears 4.5:1 and the outline clears 3:1 on --surface
     and --surface-2 in both themes (tests/fd532-brand-chip-colors.test.cjs). Own tokens, so
     retuning an accent or a status hue can never repaint a product chip. */
  --brand-cab-border:#8f9be0;
  --brand-cab-fill:#272c4a;
  --brand-cab-ink:#cdd3f5;
  --brand-stack-border:#e08cc2;
  --brand-stack-fill:#40283a;
  --brand-stack-ink:#f3c9e3;
  --shadow-c:0 1px 2px rgba(0,0,0,.38),0 8px 24px rgba(0,0,0,.5);
  --shadow-lg-c:0 2px 6px rgba(0,0,0,.5),0 18px 44px rgba(0,0,0,.62);
}

/* ---------- NO-ATTRIBUTE FALLBACK (both light AND dark) ----------
   The pre-paint bootstrap normally stamps a resolved id before anything paints, so
   this block should never be the live palette. It is the safety net for the cases
   where it isn't: a page whose <head> snippet has not run (or was stripped), the
   snippet's own catch branch, and any embed that loads theme.css alone. Without it
   the :root[data-theme] palette AND the alias layer below are inert and the page
   renders with transparent fills, missing borders and invisible selected states.

   TWO halves, because "no attribute" is not the same as "light":
     • the bare block mirrors linen-ink exactly;
     • the prefers-color-scheme:dark override below mirrors midnight-oak exactly,
   so a dark-OS visitor whose snippet never ran still gets a dark page instead of a
   white flash. That is the same resolution the bootstrap performs, expressed in CSS.

   Specificity (0,2,0) equals :root[data-theme]; once an id IS stamped the :not()
   stops matching, so the stamped theme always wins and the media override cannot
   drag a deliberately-chosen Light page dark. A page's OWN :root:not([data-theme])
   block is later in source order and still wins over this. The alias layer below
   lists :root:not([data-theme]) too, so the full vocabulary resolves either way. */
:root:not([data-theme]){
  --pd-cursor:#075c50;
  --pd-antigravity:#67368f;
  --pd-codex:#185e94;
  --pd-badge-ink:#ffffff;
  --bg:#f5f3ee;
  --surface:#ffffff;
  --surface-2:#ebe8e0;
  --surface-hi:#f0ede4;
  --text:#20242b;
  --text-muted:#565d66;
  --text-faint:#616871;
  --line:#e3e0d8;
  --border:#d2cfc5;
  --border-strong:#616871;
  --accent:#5c7c93;
  --accent-deep:#3f5a6b;
  --accent-soft:#cfe0eb;
  --header-bg:#20242b;
  --header-bg-2:#2c323b;
  --header-bg-3:#171a20;
  --header-line:#3a414b;
  --header-text:#f5f3ee;
  --ok:#5a8a6a;
  --ok-soft:#e4efe8;
  --warn:#c0720f;
  --warn-soft:#f7f0da;
  --danger:#b1503f;
  --danger-soft:#f3e1dc;
  --identity-fable:#b1503f;
  --identity-opus:#3f5a6b;
  --identity-desk-amber:#c0720f;
  --identity-desk-steel:#5c7c93;
  --brand-cab-border:#4f5ba6;
  --brand-cab-fill:#e6e8f4;
  --brand-cab-ink:#2f3a78;
  --brand-stack-border:#a14a86;
  --brand-stack-fill:#f3e4ee;
  --brand-stack-ink:#6e2b58;
  --shadow-c:0 1px 2px rgba(32,36,43,.06),0 8px 24px rgba(32,36,43,.08);
  --shadow-lg-c:0 2px 6px rgba(32,36,43,.10),0 18px 44px rgba(32,36,43,.15);
}
@media (prefers-color-scheme: dark){
  :root:not([data-theme]){
    --pd-cursor:#75dfcb;
    --pd-antigravity:#cba4ee;
    --pd-codex:#8dc6fa;
    --pd-badge-ink:#11161c;
    --bg:#11161c;
    --surface:#1a212a;
    --surface-2:#232c37;
    --surface-hi:#2c3744;
    --text:#e6edf3;
    --text-muted:#a3b0bd;
    --text-faint:#8994a1;
    --line:#283340;
    --border:#374453;
    --border-strong:#8994a1;
    --accent:#4aa3c0;
    --accent-deep:#74c4dd;
    --accent-soft:#bfe0ec;
    --header-bg:#0b1016;
    --header-bg-2:#161e27;
    --header-bg-3:#070b0f;
    --header-line:#283340;
    --header-text:#e6edf3;
    --ok:#5fb08a;
    --ok-soft:#e2efe8;
    --warn:#e8a03c;
    --warn-soft:#f7eed5;
    --danger:#dd7a6a;
    --danger-soft:#f3e0db;
    --identity-fable:#dd7a6a;
    --identity-opus:#74c4dd;
    --identity-desk-amber:#e8a03c;
    --identity-desk-steel:#4aa3c0;
    --brand-cab-border:#8f9be0;
    --brand-cab-fill:#272c4a;
    --brand-cab-ink:#cdd3f5;
    --brand-stack-border:#e08cc2;
    --brand-stack-fill:#40283a;
    --brand-stack-ink:#f3c9e3;
    --shadow-c:0 1px 2px rgba(0,0,0,.38),0 8px 24px rgba(0,0,0,.5);
    --shadow-lg-c:0 2px 6px rgba(0,0,0,.5),0 18px 44px rgba(0,0,0,.62);
    /* NB: the dark SEMANTIC overrides (--ok-text/--warn-text/--danger-text/--on-danger-bg)
       cannot live here — the alias layer below re-declares all four at the same (0,2,0)
       specificity and later source order, so it would win. They repeat in a second
       prefers-color-scheme block placed AFTER the alias layer instead. */
  }
}

/* ---------- NATIVE UA CHROME ----------
   Everything above repaints what OUR CSS draws. It says nothing to the browser about
   which way the page leans, and `color-scheme` was set nowhere in the repo - so
   getComputedStyle(document.documentElement).colorScheme read "normal" even under
   data-theme="midnight-oak", and every control the UA paints itself kept its light-mode
   form on a dark page: date-picker icons (measured 1.16:1 on the project-intake page,
   the identical 598 black pixels in BOTH themes), select popups, scrollbars, spinners,
   the caret. The OS setting does not rescue it - Chromium wants the explicit opt-in.

   These three mirror the palette blocks exactly, so the UA and our tokens can never
   disagree: the two stamped ids say which one they are, and the no-attribute fallback
   says "either", which resolves the same way its own prefers-color-scheme override does.
   Anything the page paints itself is untouched; this only reaches native chrome. */
:root[data-theme="linen-ink"] { color-scheme: light; }
:root[data-theme="midnight-oak"] { color-scheme: dark; }
:root:not([data-theme]) { color-scheme: light dark; }

/* ---------- ALIAS LAYER ----------
   Maps the canonical palette onto every legacy token name used across the
   suite. Applies to ANY themed page AND the un-themed first-visit fallback above;
   resolves to the active theme's values (or, with no attribute stamped, to the light
   fallback — or the dark one when the OS asks for dark). */
:root[data-theme], :root:not([data-theme]){
  /* surfaces / backgrounds */
  --cream:var(--bg);
  --paper:var(--bg);
  --cream-card:var(--surface);
  --cream-2:var(--surface-2);
  --surface2:var(--surface-2);
  --cream-hi:var(--surface-hi);
  --highlight:var(--surface-hi);
  /* text */
  --ink:var(--text);
  --ink-soft:var(--text-muted);
  --muted:var(--text-muted);
  --ink-faint:var(--text-faint);
  --faint:var(--text-faint);
  /* lines / borders */
  --cream-line:var(--line);
  --cream-line-2:var(--border);
  /* accent (gold / electric) */
  --gold:var(--accent);
  --gold-deep:var(--accent-deep);
  --gold-soft:var(--accent-soft);
  /* grouping-chip hue — visually distinct violet for .tag.cab (distinct from the accent/blue family) */
  --cab:#6f5bd0;
  /* SPRINT HUES (FD72, founder 2026-08-05: "I want you to color-code the Sprints to make it
     easier for me to catch them and follow them").

     REBUILT 2026-08-15 (card P728) AS A TWELVE-HUE RAMP THAT RECYCLES. This line used to hold
     thirty-two hand-picked hues, one per sprint. Two of them read brown on the founder's board
     every day - --sprint-18 (#a3622e) was a sienna on S17's pill, --sprint-16 (#8a7f65) a taupe
     on S15's - and a third, --sprint-28 (#938701), was a dark mustard on the same edge. They
     survived two earlier passes only because those passes were told to leave them alone.

     The reason they kept coming back is that one-hue-per-sprint does not scale. Thirty sprint
     hues plus nine epic hues is thirty-nine categorical colours on one page, against a research
     ceiling of about twelve for colours a reader can still tell apart, so every new token landed
     within a hair of an old one. Twelve hues, recycled, is the honest answer: the sprint chip
     always spells its sprint out in words, so colour is deliberately the second signal, and
     dev-dashboard.html spends the twelve distinct hues on the sprints that are still live and
     lets the archive share.

     THE HEXES BELOW ARE GENERATED, NOT TYPED. scripts/sprint-palette.cjs derives them, and
     tests/dev-dashboard.audit.cjs re-runs that derivation on every gate and fails if this line
     has drifted. Editing a hex here by hand is a red gate, not a change of taste. Regenerate
     with `node scripts/sprint-palette.cjs` and paste its output block.

     WHAT THE DERIVATION GUARANTEES: one lightness for all twelve (Lab L* 51.41, solved as the
     point where contrast against the light surface and against the dark page come out equal, so
     neither theme is favoured); no hue between Lab 40 and 115 degrees, which is where brown, tan,
     taupe, mustard and olive live; better than 4.2:1 against both #ffffff and #11161c, comfortably
     past the 3:1 that WCAG 1.4.11 asks of non-text; and the largest achievable smallest gap,
     dE2000 11.10, against 5.05 for the thirty-two hues this replaces.

     THEY ARE NEVER A TEXT COLOUR. tests/site-lint/theme-contrast.cjs checks a FIXED pair list, so
     a token added here is not automatically contrast-checked and a bad text pair would ship
     silently. These drive borders, dots and 12%-tint fills only; the label stays var(--text) and
     always spells the sprint out in words, so colour is never the only signal. */
  --sprint-1:#d94364;
  --sprint-2:#cf523a;
  --sprint-3:#668500;
  --sprint-4:#088e39;
  --sprint-5:#008c69;
  --sprint-6:#008985;
  --sprint-7:#00869e;
  --sprint-8:#0082be;
  --sprint-9:#4376e1;
  --sprint-10:#7a6bd6;
  --sprint-11:#a45dc2;
  --sprint-12:#c84a9e;
  /* P722 round 2: epic box hues (dev-dashboard goal sections). Assigned by epic POSITION in
     the page's own epics list - border/tint only, never text. No brown, ever.

     THIS LIST IS THE COUNT. dev-dashboard.html reads its EPIC_HUES constant off the number of
     tokens defined here, so a ninth epic can only get its own colour by a ninth token appearing
     on this line - which is what happened. Before that, the renderer wrapped at eight and the
     ninth epic's box rendered byte-identical to the first (dE2000 0.00): two different programs,
     one colour, on the tab whose whole point is telling them apart. Add a token here and raise
     EPIC_HUES in the same commit; tests/dev-dashboard.audit.cjs fails if the epics outnumber the
     tokens.

     --epic-9 is picked the same way the sprint hues are, not by eye: the largest minimum dE2000
     to the eight already here, held inside their own lightness (L* 44.6-52.9) and chroma
     (C* 33-77) envelope so no box shouts louder than its neighbours. #c64ac4 sits in the magenta
     gap between the purples (Lab hue 300-308) and the pinks (357-8) at hue 328.0, clearing its
     nearest neighbours --epic-2 by dE2000 15.43 and --epic-4 by 15.44 - more than double the set's
     own tightest pair (2/8, dE2000 6.88). Contrast 4.06:1 on white and 4.48:1 on #11161c, both
     inside the eight's own range. 
 --epic-11 (2026-08-22, added because the epics array grew to eleven when the founder
     ordered the marketing-site program).

     READ THIS BEFORE CHASING A COLOUR: a hue is assigned by the epic's ARRAY POSITION in
     dev-dashboard-data.js, as var(--epic-((index % EPIC_HUES) + 1)), NOT by its id. E11 is
     the ninth entry in that array, so E11 actually wears --epic-9, and this new token lands
     on whichever epic sits eleventh, which today is E6. Renaming or reordering the array
     moves every hue after the change. The token exists so the eleventh SLOT resolves; it was
     never about E11 specifically.

     #0d9142 is picked the same way --epic-9 was, by search rather than by eye: a true green
     at Lab hue 147.0, in the open gap between the olive --epic-5 (121.5) and the two teals
     --epic-3 (172.0) and --epic-10 (184.8), which was the widest gap the set had left. It
     holds the family envelope (L* 52.7 inside 44.6-52.9, C* 59.9 inside 33-77) so no box
     shouts louder than its neighbours, and clears its nearest neighbours by dE2000 12.38
     (--epic-5) and 12.60 (--epic-3), nearly double the set's own tightest pair (2/8 at 6.88).
     Contrast 4.08:1 on white and 4.45:1 on #11161c, both inside the ten's own range. Warm
     hues were excluded from the search outright, so nothing here can read brown, tan, taupe
     or mustard. */
  --epic-1:#2f6fbf;
  --epic-2:#7a4fd0;
  --epic-3:#0d8f74;
  --epic-4:#b8517a;
  --epic-5:#5d7f24;
  --epic-6:#d13c69;
  --epic-7:#0d84ac;
  --epic-8:#6e63e0;
  --epic-9:#c64ac4;
  --epic-10:#0e9488;
  --epic-11:#0d9142;
  /* on-accent text/fill pair for solid accent-filled pills (selected segment, M/L/XXL
     on-state). The dark theme's accent is light enough for near-black text to clear
     WCAG AA; the light theme overrides below (its mid-tone accent fails dark text, so
     it uses the deep accent + white).
     The ink was #1a1410 — a WARM near-black left over from the walnut/gold suite, and
     the last brown token on a high-traffic accent surface. Now #0f1419, the same
     lightness on the cool slate axis both surviving palettes live on. */
  --on-accent-bg:var(--accent);
  --on-accent-fg:#0f1419;
  /* Canonical on-accent pair for ANY solid accent-FILLED control (buttons, brand marks, badges,
     tags, step numbers, selected segments). --on-accent = the text, --on-accent-fill = the fill.
     Resolves per theme via the bg/fg pair above (the light theme overrides to deep-accent +
     white). Verified WCAG AA >=4.5:1 in both themes and both no-attribute states by
     tests/site-lint/theme-contrast.cjs.
     Route every "dark/white text on --gold/--accent" element here instead of hardcoding. */
  --on-accent:var(--on-accent-fg);
  --on-accent-fill:var(--on-accent-bg);
  /* P838 (BUG: the AI dock's "Ask the assistant" button read var(--accent-contrast,#fff)
     and the token was never defined, so the #fff fallback shipped unconditionally - 4.41:1
     on Linen's plain --accent and 2.88:1 on Midnight Oak's, both below the 4.5:1 AA floor at
     14px/600). --on-accent is the WRONG reuse here: it is tuned against --on-accent-fill
     (accent-DEEP in Linen), not against plain --accent, which is what shared/stack-ai-dock.js
     actually paints its buttons with. THIS is the ink for THAT fill, per theme, measured
     against --accent exactly as declared above (no existing color value changed):
       Midnight Oak: --accent #4aa3c0 (L 0.315) vs #0f1419 (the same cool-slate near-black as
         --on-accent-fg above) = 6.43:1.
       Linen: --accent #5c7c93 (L 0.188) vs any near-black ink here maxes out at 4.76:1 (pure
         black) - #0f1419 only reaches 4.20:1, which FAILS - so Linen's override below (next
         to the light-theme --on-accent-bg/-fg override) uses true black, the one value that
         clears 4.5:1 against this exact fill. Pinned by tests/site-lint/theme-contrast.cjs. */
  --accent-contrast:#0f1419;
  --electric:var(--accent);
  --electric-deep:var(--accent-deep);
  --electric-tint:var(--accent-soft);
  --accent-dim:var(--accent-deep);
  /* accent used as SMALL-TEXT fill on the light page surfaces (--surface, --cream, the #bank
     gradient). Resolves to --accent-deep, which clears WCAG AA 4.5:1 on those surfaces in BOTH
     themes unaided (the light theme's #3f5a6b is 7.3:1 on white). The per-theme --accent-text
     overrides this used to need existed only for the retired mustard-gold light themes, whose
     warm --accent-deep managed ~3.2-3.9:1; they left with those themes. Mirrors --ok-text. */
  --accent-text:var(--accent-deep);
  /* brand header / walnut family */
  --walnut:var(--header-bg);
  --walnut-2:var(--header-bg-2);
  --walnut-3:var(--header-bg-3);
  --walnut-deep:var(--header-bg);
  --walnut-line:var(--header-line);
  --walnut-tint:var(--header-bg-2);
  --wood-deep:var(--header-bg-3);
  /* semantic */
  --green:var(--ok);
  --green-bg:var(--ok-soft);
  --green-dim:var(--ok);
  --ok-bg:var(--ok-soft);
  --amber:var(--warn);
  --amber-bg:var(--warn-soft);
  --no:var(--danger);
  --red:var(--danger);
  --red-bg:var(--danger-soft);
  /* on-danger pair for SOLID danger-filled buttons (the destructive confirm). Light
     themes' --danger is a deep brick red, so white text on it already clears WCAG AA
     (~5:1). The dark themes tune --danger as a BRIGHT terracotta accent (so over-wall
     markers read on dark surfaces), where white text would be only ~3:1 — they override
     --on-danger-bg with a deeper red below. Keep --danger as the button BORDER so its
     boundary stays >=3:1 against the (dark) modal card. Mirrors the --on-accent pair. */
  --on-danger-bg:var(--danger);
  --on-danger-fg:#ffffff;
  /* dark "ink" text for use ON the light semantic soft-fills (the *-soft tints stay
     light in every theme), so chips/badges/readouts clear WCAG AA in all themes.
     ACCENT text on a light --accent-soft fill: use --accent-ink (fixed deep slate), NOT
     --accent-text (which resolves bright in dark themes and fails on the light soft fill).

     FD164=A (2026-08-17, card P820): --warn-ink WAS #6e5114, and a dark amber IS a
     brown - hue 38 degrees, 68% saturation, 25% lightness is the textbook definition.
     Every other semantic ink can carry its family's hue at low lightness and stay
     itself (dark green, dark red, dark blue); dark amber cannot, it becomes mud. So
     the warn family splits the job the way a caution sign does: the AMBER FILL carries
     the meaning (--warn and --warn-soft are unchanged, by the founder's ruling) and the
     ink is simply the darkest readable neutral on it. #3a3f47 is the Linen theme's own
     cool-slate ink family (it sits between --text #20242b and --accent-ink #3f5a6b),
     so a warning chip reads as page ink on amber rather than as a second brown.
     Measured: 9.30:1 on the light --warn-soft #f7f0da, 9.16:1 on the dark --warn-soft
     #f7eed5, 9.56:1 on --bg, 10.60:1 on --surface, 8.65:1 on --surface-2 - AAA in
     every state, against a 4.5:1 requirement. Pinned by tests/site-lint/theme-contrast.cjs. */
  --ok-ink:#2f5a3f;
  --warn-ink:#3a3f47;
  --danger-ink:#7a3326;
  --accent-ink:#3f5a6b;
  /* theme-adaptive semantic TEXT — for semantic-coloured text drawn on a surface that
     FLIPS dark (page canvas --bg/--cream, --surface/--cream-card, --surface-2, menus).
     Resolves to the deep -ink in light themes and a bright AA tint in dark (override
     below). Mirrors --on-accent / --on-danger. For text on the *-soft fills (which stay
     light in every theme) keep -ink; for text on the hardcoded light --drop-bg keep -ink. */
  --ok-text:var(--ok-ink);
  --warn-text:var(--warn-ink);
  --danger-text:var(--danger-ink);
  /* semantic text on a PERMANENTLY-DARK surface (the inventory rail / header family,
     dark in EVERY theme): always the bright tint so light themes clear AA on it too.

     --accent-on-dark completes the family (2026-08-21, the Tools-launcher redesign). The
     accent was the one member missing, and its absence was a live WCAG failure rather than
     a tidiness gap: every focus ring on the dark app bar read --accent-text, which resolves
     to --accent-deep and goes DEEP in light themes, so in Linen the keyboard ring on the
     launcher measured 2.14:1 against --header-bg and 1.77:1 against --header-bg-2 - under
     the 3:1 SC 2.4.11 asks of a focus indicator, on a bar that is dark in EVERY theme.
     #74c4dd is not a new colour: it is midnight-oak's own --accent-deep above, given a name
     so the permanently-dark surfaces can ask for it by intent. Measured 7.92:1 / 6.57:1
     (Linen) and 9.71:1 / 8.55:1 (Midnight Oak); both pairs are pinned in
     tests/site-lint/theme-contrast.cjs. */
  --ok-on-dark:#9ed39a;
  --warn-on-dark:#e8c067;
  --danger-on-dark:#f0a98f;
  --accent-on-dark:#74c4dd;
  /* P2066 — THE ONE FOCUS-RING TOKEN (R-16). The design-authority audit measured 7 distinct
     ring shapes across cabinet-designer.html and systainer-bank.html for one affordance
     ("a control has focus"): 87 --accent-text / 1 --gold on cabinet-designer.html, 4
     --accent-text / 16 --gold on systainer-bank.html, plus --accent/--gold-deep/--on-accent/
     --header-text/--danger-text scattered in. Harvested value, decided by pixel measurement
     against the five worst backdrops in both themes (contrast.mjs, this piece):
       --accent-text (=--accent-deep) vs --surface / --surface-hi: 7.28/6.22 (Linen),
         8.25/6.15 (Midnight Oak) - clears 3:1 everywhere EXCEPT the topbar family below.
       --gold (=--accent) vs the SAME surfaces: 4.41/3.77 (Linen), 5.63/4.20 (Midnight Oak) -
         also clears, by a thinner margin, but FAILS the topbar test just like --accent-text
         does (see below) - so switching to --gold instead would not have escaped the
         per-surface override this piece needs anyway.
       Topbar family (--header-bg / --header-bg-2, dark in EVERY theme): --accent-text
         measures 2.14:1 / 1.77:1 in Linen (FAILS 3:1, already documented above as the
         --accent-on-dark rationale); --gold measures 3.53:1 / 2.93:1 in Linen (the SECOND
         number also fails). Midnight Oak's own --accent-text already equals --accent-on-dark
         (#74c4dd) so it passes there regardless.
     Decision: --focus-ring-color is --accent-text (the stronger, more-adopted default);
     controls painted on the permanently-dark topbar/header/rail family use --accent-on-dark
     instead (already built for exactly this by the block above) rather than a new colour.
     Width/offset: 2px / 2px matches the majority convention already shipped (this page's own
     global `:focus-visible` default block, most of both flagship pages' individual rings).
     A control whose ring width or offset differs from 2px/2px for a real geometry reason
     (an inset ring inside a scrolling row, a bigger ring on a small canvas glyph) keeps its
     own literal width/offset and only its COLOUR moves onto this token - listed as such,
     never silently. Enforced by tests/focus-ring.audit.cjs. */
  --focus-ring-color:var(--accent-text);
  --focus-ring-width:2px;
  --focus-ring-offset:2px;
  /* =====================================================================
     P164 / P166 / P167 — the PLAN CLASS-COLOR language. ONE hue per
     cabinet class drives THREE derived tokens that the floor-plan box
     FILL, the on-box NAME label, the dimension LADDER (lines + numbers +
     tag) and the legend all read, so the fill, the label, the ladder and
     the key can never disagree — one colour language, one source.
       --cc-<class>-hue    the class's base hue (a theme token by default;
                           P167 overrides just THIS at the #plan-panel scope
                           and the fill + ink re-derive automatically).
       --cc-<class>-fill   the light box tint = the P108/P120 formula
                           (hue mixed into --surface), byte-identical to
                           the old inline .cabfill-* mixes, per class.
       --cc-<class>-ink    the DEEP variant for label text + ladder line/
                           number: the hue driven toward --text so it stays
                           AA on --surface AND on its own fill in EVERY theme
                           (light themes -> darker, dark themes -> lighter,
                           by construction). "Inkier than the fill," derived
                           — never the raw fill value. The P167 runtime floor
                           deepens a user pick further only when it must. */
  --cc-base-hue:var(--accent);
  --cc-upper-hue:var(--ok);
  --cc-tall-hue:var(--danger);
  --cc-island-hue:var(--cab);
  --cc-appliance-hue:var(--text-muted);
  --cc-base-fill:color-mix(in srgb, var(--cc-base-hue) 24%, var(--surface));
  --cc-upper-fill:color-mix(in srgb, var(--cc-upper-hue) 48%, var(--surface));
  --cc-tall-fill:color-mix(in srgb, var(--cc-tall-hue) 24%, var(--surface));
  --cc-island-fill:color-mix(in srgb, var(--cc-island-hue) 34%, var(--surface));
  --cc-appliance-fill:color-mix(in srgb, var(--cc-appliance-hue) 20%, var(--surface));
  --cc-base-ink:color-mix(in srgb, var(--cc-base-hue) 52%, var(--text));
  --cc-upper-ink:color-mix(in srgb, var(--cc-upper-hue) 52%, var(--text));
  --cc-tall-ink:color-mix(in srgb, var(--cc-tall-hue) 55%, var(--text));
  --cc-island-ink:color-mix(in srgb, var(--cc-island-hue) 55%, var(--text));
  --cc-appliance-ink:color-mix(in srgb, var(--cc-appliance-hue) 40%, var(--text));
  /* effects */
  --shadow:var(--shadow-c);
  --shadow-lg:var(--shadow-lg-c);
  /* P58 — tokenized ELEVATION SCALE (a depth vocabulary; adding tokens is not
     palette work, and it does not touch any theme's colours). Apply consistently:
       --elev-1  resting cards / tiles          (= the existing card shadow)
       --elev-2  popovers, dropdowns, toasts    (= the existing large shadow)
       --elev-3  modals (they sit over a scrim) — a monochrome ambient so it reads
                 as the highest layer on ANY theme without per-theme tuning.
     A DOCKED panel / inspector divider stays a BORDER, never a shadow. */
  --elev-1:var(--shadow-c);
  --elev-2:var(--shadow-lg-c);
  --elev-3:0 2px 8px rgba(0,0,0,.12),0 28px 64px rgba(0,0,0,.26);
}
/* The LIGHT theme's mid-tone accent (#5c7c93) fails dark text on a solid fill, so the
   on-accent pill uses the deep accent + white (~7.3:1). The no-attribute LIGHT fallback
   rides the same rule, since it mirrors linen-ink token for token. Placed after
   the alias layer so equal-specificity source order lets it win.
   (The retired gold/warm light themes each carried an --accent-text override here to
   drag their mustard --accent-deep up to AA; linen-ink's #3f5a6b is 7.3:1 on white
   unaided, so the whole override family went with them.) */
:root[data-theme="linen-ink"], :root:not([data-theme]){
  --on-accent-bg:var(--accent-deep);
  --on-accent-fg:#ffffff;
  /* P838: Linen's plain --accent (#5c7c93, the fill shared/stack-ai-dock.js actually
     paints its buttons with) sits in the one luminance band no readable ink clears
     4.5:1 against except true black - #0f1419 (the default above) only reaches 4.20:1.
     True black is the maximum any dark ink can reach against this fill (4.76:1) and is
     neutral (0% saturation), never brown. */
  --accent-contrast:#000000;
}
/* The DARK theme tunes --danger as a bright terracotta ACCENT (over-wall bars/markers on
   dark surfaces), so white text on it is only ~3:1. Solid danger BUTTONS therefore use
   a deeper red fill (white text ~5:1) while keeping the bright --danger as the border.
   Placed after the alias layer so equal-specificity source order lets it win. */
:root[data-theme="midnight-oak"]{
  --on-danger-bg:#b35140;
  /* semantic TEXT goes bright on the dark surfaces (the fixed dark -ink tints drop to
     ~1.5-2.1:1 there). Verified 7.0-10.7:1 on every dark surface. */
  --ok-text:#9ed39a;
  --warn-text:#e8c067;
  --danger-text:#f0a98f;
}
/* …and the same two overrides for the no-attribute DARK state (snippet never ran, OS is
   dark). Must sit AFTER the alias layer for the identical source-order reason; the base
   midnight-oak palette for this state is up in the first prefers-color-scheme block. */
@media (prefers-color-scheme: dark){
  :root:not([data-theme]){
    /* the light fallback's on-accent override (deep accent + white) is wrong here: on the
       dark theme the accent is light enough that near-black ink is the AA-clearing pair,
       so revert to the alias layer's defaults for this state. Same reasoning for
       --accent-contrast (P838): the light block's #000000 targets this same bare
       :root:not([data-theme]) selector unconditionally, so it has to be reverted here too
       or an OS-dark visitor whose snippet never ran would get Linen's black instead of
       the dark-appropriate #0f1419 (still >4.5:1 against midnight-oak's brighter --accent). */
    --on-accent-bg:var(--accent);
    --on-accent-fg:#0f1419;
    --accent-contrast:#0f1419;
    --on-danger-bg:#b35140;
    --ok-text:#9ed39a;
    --warn-text:#e8c067;
    --danger-text:#f0a98f;
  }
}
/* Smooth the flip; respect reduced-motion. Targets :root body (0,1,1) so the FIRST
   theme application (un-themed fallback → chosen theme) animates too, not just
   theme-to-theme switches. */
@media (prefers-reduced-motion: no-preference){
  :root body{ transition: background-color .25s ease, color .25s ease; }
}

/* =====================================================================
   P58 — the suite MOTION STANDARD. State changes animate in 150-250ms,
   transform/opacity ONLY (never width/height/top/left — those thrash
   layout), and are SILENCED under prefers-reduced-motion. Shared as tokens
   + opt-in helper classes so nothing animates unless it asks for it.
     --bws-motion / --bws-motion-slow  standard duration+easing for
       per-component transitions, e.g. `transition: opacity var(--bws-motion)`.
     .bws-fade-in / .bws-pop-in  enter animations for panels/menus/toasts.
   ===================================================================== */
:root{
  --bws-ease:cubic-bezier(.4,0,.2,1);
  --bws-motion:.18s var(--bws-ease);
  --bws-motion-slow:.24s var(--bws-ease);
  /* FD141 (2026-08-15) - the founder's answer on the tooltip hover delay: "Go with 500
     milliseconds." FD937 B (2026-11-02), his newer word, lowered it: "Lower the first
     hover to 300 ms." The 300 ms below is that answer. Every tooltip driver in the suite
     reads this one token instead of its own hardcoded number. Keyboard focus stays
     INSTANT everywhere (accessibility). */
  --bws-tip-delay:300ms;
  /* docs/research/tooltip-delay-standard.md section 3 - the "warm window" companion to
     the delay above: reserved for the sibling-instant pattern (Radix skipDelayDuration,
     MUI enterNextDelay) where moving from one already-warm trigger to a neighbor shows
     the next tip instantly instead of re-arming the full first-hover wait. NOT WIRED UP YET -
     no driver reads this token today (that behavior was research-recommended, not asked
     for; FD141 only settled the delay NUMBER). Declared now so the token exists the day
     a driver adds the behavior, same as --bws-tip-delay was declared before every
     consumer read it. */
  --bws-tip-skip:300ms;
}
@media (prefers-reduced-motion: no-preference){
  .bws-fade-in{ animation:bws-fade-in .18s var(--bws-ease) both; }
  .bws-pop-in{ animation:bws-pop-in .2s var(--bws-ease) both; }
  @keyframes bws-fade-in{ from{ opacity:0; transform:translateY(4px); } to{ opacity:1; transform:none; } }
  @keyframes bws-pop-in{ from{ opacity:0; transform:scale(.96); } to{ opacity:1; transform:none; } }
}
@media (prefers-reduced-motion: reduce){
  .bws-fade-in, .bws-pop-in{ animation:none !important; }
}

/* =====================================================================
   P2038 — FOUNDATIONS: the spacing and radius scales, HARVESTED, not
   invented, from the four pages that had each independently built their
   own (docs/design-system-plan-2026-09-10.md §3 "Radius / spacing
   tokens", §4b). Purely additive: nothing reads these tokens yet, so no
   page repaints. Full harvest table:
   docs/design-system/foundations-spacing-radius.md.

   SPACING (--space-1..8) is stackdesign-home.html's own named scale,
   copied verbatim (stackdesign-home.html:245: `--s1:8px; --s2:12px;
   --s3:16px; --s4:24px; --s5:32px; --s6:48px; --s7:64px; --s8:96px`) —
   the only one of the four pages that had already built a graduated
   ramp rather than a handful of loose values. It checks out against the
   other three: index.html's lone gutter token, `--gut:32px`
   (index.html:78), lands exactly on --space-5; and the single most-used
   `gap:` literal on both hand-rolled app pages is 8px (systainer-bank.html,
   28 sites; dev-dashboard.html, 52 sites), landing exactly on --space-1.
   index.html's own `--sec:112px` (index.html:80, section-level vertical
   rhythm, not a component gap) sits outside this ramp on purpose and is
   not folded in.

   RADIUS (--radius-sm/md/lg/pill) is a four-way consensus, one value at
   a time, since no single page had already built all four:
     sm   10px  stackdesign-home.html's --r-1 AND dev-dashboard.html's
                --r-sm agree exactly (stackdesign-home.html:244,
                dev-dashboard.html:33); index.html's --r-chip (8px,
                index.html:83) is the nearest neighbour.
     md   14px  THREE of four pages land here exactly: index.html's
                --r-card, stackdesign-home.html's --r-2, and
                dev-dashboard.html's own --r (index.html:83,
                stackdesign-home.html:244, dev-dashboard.html:33);
                systainer-bank.html's lone --r (12px,
                systainer-bank.html:109) is the nearest neighbour.
     lg   20px  index.html's --r-panel (index.html:83), the only page to
                name a radius role this specific ("large panels"); the
                nearest neighbour is stackdesign-home.html's most-USED
                radius, --r-3 (22px, 11 call sites, stackdesign-home.html:244).
     pill 999px the fully-round idiom: a raw literal on all four pages,
                and each page's own single most common non-text radius
                wherever a page reaches for a literal instead of a named
                token: dev-dashboard.html (65 sites), stackdesign-home.html
                (18), systainer-bank.html (16), index.html (5).
   Neither scale differs by theme (unlike colour, FD166), so both live
   once on a plain :root — no [data-theme="..."] duplication needed.

   --radius-xs (2026-09-11 fix, the design authority's audit finding P1-4):
   8px, harvested from the two places that had already shipped this exact
   number with no shared token behind it: shared/bws-buttons.css's
   --btn-radius-md/-lg (8px, that file's own header calls it "deliberately
   NOT the foundation --radius-md") and index.html's --r-chip (8px). Both
   are re-pointed at this token with their old literal kept as the var()
   fallback, so nothing repaints; --btn-radius-md itself stays a plain
   literal (a sibling test, tests/bws-buttons.test.cjs, pins its raw
   declared value and is outside this piece's scope to edit), so only
   --btn-radius-lg and --r-chip actually read the new token today. */
:root{
  --space-1:8px;
  --space-2:12px;
  --space-3:16px;
  --space-4:24px;
  --space-5:32px;
  --space-6:48px;
  --space-7:64px;
  --space-8:96px;
  --radius-xs:8px;
  --radius-sm:10px;
  --radius-md:14px;
  --radius-lg:20px;
  --radius-pill:999px;
}

/* =====================================================================
   P2073 — THE SKELETON COMPONENT (R-20's third wait-state shape:
   docs/design-system/rules.md "a wait state is exactly a skeleton, a
   spinner, or a disabled control"). Ships the ONE shape neither flagship
   page had an example of before this piece: a placeholder matching the
   KNOWN LAYOUT of the content about to arrive, for a wait past about
   400ms. Component record + the three-shape decision in full:
   docs/design-system/components/skeleton.md (cites the existing
   docs/design-system/components/progress.md, R-20's own stub).

   THREE SHAPES, ONE BASE CLASS. `.skeleton` alone paints a block
   (var(--radius-sm), the same corner every plain card/panel uses).
   `.skeleton.skeleton-line` narrows it to one text-row's height and
   rounds both ends (var(--radius-pill)) so a run of them reads as
   placeholder COPY, not placeholder boxes. `.skeleton.skeleton-rounded`
   is fully circular (an avatar/icon/swatch stand-in). All three share one
   shimmer: a soft diagonal sweep from --surface-2 (the resting tone) to
   --surface-hi (the highlight) and back, so it reads correctly in both
   themes with zero extra rules — no theme-scoped override needed, unlike
   the colour tokens above, because both stops are already theme-aware.

   ACCESSIBILITY CONTRACT (a screen reader must never read a placeholder,
   and must know a region is busy): the CSS alone cannot carry this — the
   two attributes belong on the markup, not a class — so it is stated here
   as the rule every consumer follows. Every `.skeleton` element carries
   `aria-hidden="true"` (its shape and shimmer are a sighted-only loading
   cue; nothing about it should ever reach a screen reader as if it were
   content). The CONTAINER being filled carries `aria-busy="true"` for as
   long as the skeleton is showing, cleared the moment the real content
   replaces it — the same busy/idle contract `aria-busy` exists for.

   MOTION: the shimmer is a continuous, not a one-shot, animation, so it
   deliberately does NOT reuse --bws-motion/-slow above (those are
   150-250ms per-component TRANSITIONS, the wrong shape for a loop) — the
   same "own literal duration, shared easing token" precedent .bws-fade-in/
   .bws-pop-in already set two blocks up. Reduced motion drops the sweep
   entirely and shows the flat resting tone, never a frozen mid-shimmer
   frame, matching systainer-bank.html's own .batch-progress-spinner
   silencing convention (rules.md R-20, "Where it lives here"). ~*~
   ===================================================================== */
@media (prefers-reduced-motion: no-preference){
  .skeleton{
    display:block; border-radius:var(--radius-sm);
    background:linear-gradient(100deg, var(--surface-2) 35%, var(--surface-hi) 50%, var(--surface-2) 65%);
    background-size:250% 100%;
    animation:bws-skeleton-shimmer 1.6s var(--bws-ease) infinite;
  }
  @keyframes bws-skeleton-shimmer{ from{ background-position:200% 0; } to{ background-position:-50% 0; } }
}
@media (prefers-reduced-motion: reduce){
  .skeleton{
    display:block; border-radius:var(--radius-sm);
    background:var(--surface-2);
    animation:none;
  }
}
.skeleton.skeleton-line{ height:1em; min-height:14px; border-radius:var(--radius-pill); }
.skeleton.skeleton-rounded{ border-radius:50%; }

/* R-20's SPINNER SHAPE, PROMOTED (docs/design-system/rules.md, "a wait state is
   exactly a skeleton, a spinner, or a disabled control"). Until this piece the
   only spinner anywhere in the product was systainer-bank.html's own page-local
   .batch-progress-spinner (:428, silenced at :451 under reduced motion) - a
   second, page-local copy of the same mechanism was exactly the drift R-20 and
   AFM-01 (docs/AGENT-FAILURE-MODES.md) exist to end. .spinner is that mechanism,
   promoted here as the ONE shared definition instead: a small rotating ring, the
   resting ring on --border and the moving edge on --accent - both theme tokens
   already used two blocks up for the skeleton's own shimmer stops, so this reads
   correctly in Linen and Midnight Oak with no [data-theme] override needed.
   Reduced motion stops the rotation and leaves the resting ring showing, never a
   frozen mid-spin frame - the same convention the skeleton block above follows.

   systainer-bank.html's own .batch-progress-spinner keeps its current page-local
   rule for now (a one-line follow-up - point its body at .spinner instead of
   repeating it - is named for the next lane to touch that file; this piece's
   OWNS list is cabinet-designer.html + shared/theme.css only, never
   systainer-bank.html) but already resolves to the SAME colour: that rule's own
   border-top-color: var(--gold) is --gold: var(--accent) (this file, the alias
   two sections down), so nothing will visibly move when that follow-up lands.

   First consumer: cabinet-designer.html's AI Designer dock "Thinking" indicator
   (the pending assistant bubble shown between Send and the first streamed
   token) - see docs/design-system/components/progress.md. */
@media (prefers-reduced-motion: no-preference){
  .spinner{
    display:inline-block; flex:0 0 auto; width:16px; height:16px; border-radius:50%;
    border:2px solid var(--border); border-top-color:var(--accent);
    animation:bws-spin 0.7s linear infinite;
  }
  @keyframes bws-spin{ to{ transform:rotate(360deg); } }
}
@media (prefers-reduced-motion: reduce){
  .spinner{
    display:inline-block; flex:0 0 auto; width:16px; height:16px; border-radius:50%;
    border:2px solid var(--border); border-top-color:var(--accent);
    animation:none;
  }
}

/* P2122 — THE THIRD R-20 WAIT-STATE SHAPE'S OWN LABEL, kept readable while it is
   still true. "A wait on a single control's own action disables that control and
   swaps its own label to a busy verb" (rules.md R-20) - but the generic disabled
   look every button already carries (opacity ~0.45-0.6, tuned so a control that
   will NEVER respond again reads as inert) also dims that swapped-in label, which
   is exactly the text a person mid-wait most needs to read. A busy control is
   disabled so a second click cannot queue, never because there is nothing left
   worth reading, so its label stays at full strength.

   PROMOTED HERE FROM cabinet-designer.html's own page-local `.ai-send.busy:disabled`
   (P2098), which shipped this shape for the AI Designer composer's "Sending" label
   alone (measured 7.28:1 Linen / 6.43:1 Midnight Oak against the on-accent fill it
   sits on). The photo-capture flow's "Refining photo..." label (`#three-photo-btn`,
   `threeTakePhoto()`) carried the identical disabled-and-relabeled shape with no
   override and measured ~2.74:1 - the same defect, a second button, missed only
   because it sat outside the #P2098 lane's own scope.

   KEYED ON `aria-busy="true"`, NOT THE BARE `.busy` CLASS (review fix round). The
   first cut of this rule read `button.busy:disabled`, which collides with a
   DIFFERENT, older meaning of `.busy` this repo already shipped:
   modules/account-admin/billing-test.html's own request-in-flight buttons
   (`post()`/`release()`/`failRenewal()`) add `.busy` to a disabled button whose
   LABEL NEVER CHANGES, relying on that page's own `.btn[disabled] { opacity: .5 }`
   to keep reading as off. The bare-class selector's higher specificity silently
   overrode that page's own dim, making three admin flows look active while
   disabled - a real regression this rule shipped once already, caught on review.
   `aria-busy="true"` is the honest, narrower signal: it means specifically "this
   control's own label now describes what it is doing", which is exactly the one
   case that needs to stay at full strength, and it is also the attribute a screen
   reader announces - so the fix is not just safer, it is the more correct signal
   for what this rule actually means. `.busy` itself is unaffected and stays wired
   wherever a button needs its OTHER busy-state styling (width/padding grow,
   etc.) - this rule simply no longer keys the opacity override off it.

   `button[aria-busy="true"]:disabled` (element + attribute + pseudo, specificity
   0,0,2,1) still deliberately outranks any single-class `.foo:disabled` rule
   (0,0,2,0) a page defines for its own base disabled look, so this fires
   regardless of source order - no `!important`, no page-local override needed to
   make it win - while never matching a button that merely carries `.busy` with
   no relabel. Tokens only: no colour changes, only the opacity override. */
button[aria-busy="true"]:disabled { opacity: 1; }

/* =====================================================================
   P2039 — THE TYPE SCALE: one block, in theme.css, for two systems that
   had each separately settled on almost the same numbers and never been
   told they agreed. docs/design-system/page-layout.md §10 codifies a
   literal rem table for every logged-out "System A" page (h1 2rem/1.15,
   h2 1.7rem/1.25, lede 1.2rem/1.55, body 1rem/1.6, fine .875rem/1.55),
   and shared/site-header.css §3d's `.mkt-page` block — built independently
   for the seven StackDesign marketing pages — landed on --mkt-h1:2rem/
   -lh:1.15, --mkt-h2:1.7rem/-lh:1.25 and --mkt-lede:1.2rem/-lh:1.55: THE
   SAME THREE ROLES, to the rem. docs/design-system-plan-2026-09-10.md §3
   "Type scale" named the divergence — two systems, each claiming to be
   "the" scale, never reconciled — and this block is the reconciliation:
   the numbers live here ONCE, so a later edit to a role moves both
   systems together instead of only the one somebody happened to touch.
   Full harvest and citations: docs/design-system/foundations-type.md.

   ROLES NOT HERE, ON PURPOSE. "display" — the two front doors' own larger
   opening heading — is left out: index.html hardcodes
   `clamp(2.5rem,4.4vw,3.7rem)` at line-height 1.04 and
   stackdesign-home.html's `.mkt-page.mkt-door` reads
   `clamp(2.4rem,4.4vw,3.35rem)` at 1.03 — genuinely different numbers for
   the same "front door" role, not a naming accident, and reconciling them
   is its own decision, not a side effect of folding the other five roles
   together (foundations-type.md names this as the one thing left open).
   `--mkt-sec` (the marketing section beat) and `--mkt-band-h2`/
   `--mkt-band-p` (the closing CTA band) are also left out: neither has a
   role in the §10 table at all, so there is nothing here for them to
   resolve to.

   Same theming note as the spacing/radius block above: a type scale is
   geometry, not palette, so it lives once on a plain :root with no
   [data-theme="..."] duplication. */
:root{
  --type-h1-size:2rem;
  --type-h1-lh:1.15;
  --type-h2-size:1.7rem;
  --type-h2-lh:1.25;
  --type-lede-size:1.2rem;
  --type-lede-lh:1.55;
  --type-body-size:1rem;
  --type-body-lh:1.6;
  --type-fine-size:.875rem;
  --type-fine-lh:1.55;
}

/* P58 — alignment for the shared line-icon hosts (shared/bws-icons.js fills any
   [data-bws-icon] span with an inline SVG). Centres the glyph inside
   buttons/tabs (flex ignores vertical-align) AND inline labels/headings
   (vertical-align:middle), without per-page CSS. */
[data-bws-icon]{ display:inline-flex; align-items:center; flex:0 0 auto; vertical-align:middle; }
