/* ──────────────────────────────────────────────────────────────────────────
   tokens.css — the single, theme-agnostic foundation layer
   ----------------------------------------------------------------------------
   WHY THIS FILE EXISTS
   The app grew with NINE separate `:root` blocks (style.css, dashboard.css,
   field.css, …) and ~3,800 hardcoded `px` literals. There was no spacing scale
   and no type scale, so every panel re-invented its own rhythm and long strings
   / unusual viewports caused layout shift and clipping.

   This file is loaded *first* on every page, BEFORE the page stylesheet and any
   theme (classic / ui-next / theme-calm). It only DEFINES NEW token names
   (--sp-*, --text-*, --leading-*, --ease-*, --dur-*, --z-*) — it never collides
   with the existing colour/radius/shadow tokens, so it is purely additive and
   cannot regress the current look. Themes keep overriding colours/radii on top.

   The values are chosen to map 1:1 onto the px literals already in the codebase
   (4 / 6 / 8 / 10 / 12 / 14 / 16 / 20 / 24 …) so migration is a safe,
   like-for-like substitution rather than a redesign.
   ────────────────────────────────────────────────────────────────────────── */

:root {
  /* ── Spacing scale (4px base grid) ───────────────────────────────────────
     Use for padding / margin / gap. Picked to match the literals already in
     use so swaps are visually identical. Prefer these over raw px. */
  --sp-0:    0;
  --sp-px:   1px;
  --sp-0_5:  2px;
  --sp-1:    4px;
  --sp-1_5:  6px;
  --sp-2:    8px;
  --sp-2_5:  10px;
  --sp-3:    12px;
  --sp-3_5:  14px;   /* very common gap/padding in the current UI */
  --sp-4:    16px;
  --sp-5:    20px;
  --sp-6:    24px;
  --sp-7:    28px;
  --sp-8:    32px;
  --sp-10:   40px;
  --sp-12:   48px;
  --sp-16:   64px;

  /* ── Job-status colour family ────────────────────────────────────────────
     Shared semantic palette for dispatch job statuses (chips, badges, legend,
     records). Single source of truth; mirrors JOB_STATUS in
     js/scheduler/state.js — keep the two in sync. Chosen for mutual contrast:
     teal en-route no longer collides with the blue accent, and
     scheduled/cancelled are no longer near-identical greys. */
  --st-scheduled:   #64748b;   /* slate — booked */
  --st-en_route:    #14b8a6;   /* teal — moving */
  --st-in_progress: #f59e0b;   /* amber — on site */
  --st-complete:    #22c55e;   /* green — done */
  --st-no_show:     #ef4444;   /* red — missed */
  --st-cancelled:   #94a3b8;   /* muted — called off */
  --st-invoiced:    #8b5cf6;   /* violet — billed */

  /* ── Calendar-note swatches ──────────────────────────────────────────────
     User-picked sticky-note colours on the dispatch board (calendar_notes.color
     stores the NAME — 'amber' | 'blue' | … — so restyling here restyles every
     existing note instead of freezing a palette into the data).
     Deliberately DESATURATED against --st-* above: a note is not a status, and
     must never read as one at a glance across a busy board. */
  --sched-note-amber: #d9a441;
  --sched-note-blue:  #6aa3d8;
  --sched-note-green: #6bbf7f;
  --sched-note-red:   #d9736f;
  --sched-note-grey:  #94a3b8;

  /* ── Document-status colour family (quotes + invoices) ───────────────────
     Sibling of --st-* above, for the neuralbooks lifecycle. Single source of
     truth for quote/invoice badges (js/core/ui/badge.js + css/ui.css) —
     replaces the inline hex maps formerly duplicated in quotes-page.js and
     invoices-page.js. Hues are shared with --st-* on purpose so the same
     meaning carries one colour across every app:
       green  = money-good / done      amber = waiting / needs attention
       red    = act now                blue  = in flight (customer's court)
       slate  = not-yet-real (draft/void)   cyan = engagement (viewed)
     Chosen to stay legible on both the light panel and the dark (books
     default) panel when paired with a ~14% tint of themselves. */
  --doc-draft:     #94a3b8;   /* slate — not yet real */
  --doc-sent:      #60a5fa;   /* blue  — in the customer's court */
  --doc-viewed:    #22d3ee;   /* cyan  — customer opened it */
  --doc-viewing:   #34d399;   /* green — customer has it on screen right now (core/doc-views.js) */
  --doc-accepted:  #22c55e;   /* green — won */
  --doc-open:      #60a5fa;   /* blue  — awaiting payment */
  --doc-partial:   #f59e0b;   /* amber — partially paid */
  --doc-paid:      #22c55e;   /* green — settled */
  --doc-overdue:   #ef4444;   /* red   — act now */
  --doc-declined:  #ef4444;   /* red   — lost */
  --doc-expired:   #f59e0b;   /* amber — lapsed */
  --doc-void:      #8b93a3;   /* muted — withdrawn */

  /* ── Type scale ──────────────────────────────────────────────────────────
     Small UI text stays FIXED on purpose — dense data tables should not reflow
     fluidly with the viewport (that hurts legibility and alignment). Only the
     display/heading sizes scale fluidly via clamp() so big headings stay
     proportional on phones and ultrawide monitors without media queries. */
  --text-2xs:  10px;
  --text-xs:   11px;
  --text-sm:   12px;
  --text-base: 13px;   /* app body default */
  --text-md:   14px;
  --text-lg:   15px;
  --text-xl:   16px;
  --text-2xl:  18px;
  /* Fluid display sizes: clamp(min, preferred-vw, max). */
  --text-h3:   clamp(18px, 1.2vw + 14px, 22px);
  --text-h2:   clamp(20px, 1.8vw + 14px, 28px);
  --text-h1:   clamp(24px, 3vw + 16px, 40px);
  --text-display: clamp(32px, 5vw + 16px, 64px);

  --leading-tight:  1.2;
  --leading-snug:   1.35;
  --leading-normal: 1.55;
  --leading-relaxed: 1.7;

  --weight-regular:  400;
  --weight-medium:   500;
  --weight-semibold: 600;
  --weight-bold:     700;

  /* ── Motion ──────────────────────────────────────────────────────────────
     Names the existing --spring/--transition tokens did not provide. The
     primitives (states.js, disclosure.js, stagger.js) read these so motion is
     consistent and tunable from one place. */
  --dur-1: 80ms;    /* taps / instant state flips */
  --dur-2: 150ms;   /* default — matches the legacy --transition */
  --dur-3: 240ms;   /* panels, popovers, list entry */
  --dur-4: 360ms;   /* large / modal-scale movement */
  --ease-standard: cubic-bezier(.2, .8, .2, 1);   /* matches legacy --spring */
  --ease-out:      cubic-bezier(0, 0, .2, 1);
  --ease-in-out:   cubic-bezier(.4, 0, .2, 1);
  --ease-emphasis: cubic-bezier(.2, .9, .1, 1);

  /* ── Z-index ladder ──────────────────────────────────────────────────────
     A named ladder prevents the magic-number z-index wars (the app already has
     z:30 topbars, ad-hoc modal overlays, toasts on body, …). */
  --z-base:     0;
  --z-sticky:   20;
  --z-topbar:   30;
  --z-dropdown: 100;
  --z-overlay:  900;
  --z-modal:    1000;
  --z-toast:    1100;

  /* Standard focus-ring offset, so every focusable shares one rhythm. */
  --focus-ring: 2px solid var(--accent, #3b82f6);
  --focus-offset: 2px;

  /* ── Unified nav tokens (see docs/nav-unification-plan.md) ────────────────
     ONE palette for the shared top bar (Hub · Books · Dispatch), replacing the
     three name-spaces that today hold IDENTICAL values under different names:
       books  --inv-bg/--inv-surface/--inv-card/--inv-text/--inv-accent
       sched  --bg/--bg-secondary/--surface/--text/--accent
       hub    --paper/--sunken/--panel/--ink/--accent
     Dark is the default (books + dispatch are dark-first); the light overrides
     live in the block below, keyed off every app's light trigger. Nothing
     consumes these yet — the shared nav.js component (Phase 2) is the first
     reader, so adding them is purely additive and cannot regress today's UI. */
  --nd-bg:          #0e0f12;
  --nd-surface:     #15171c;
  --nd-card:        #1b1e25;
  --nd-card-hover:  #242832;
  --nd-text:        #f2f4f8;
  --nd-text-2:      #aeb6c4;
  --nd-muted:       #727b8b;
  --nd-accent:      #33b1ff;
  --nd-accent-soft: rgba(51,177,255,.16);
  --nd-accent-edge: rgba(51,177,255,.45);
  --nd-hair:        rgba(255,255,255,.08);
  --nd-hair-2:      rgba(255,255,255,.13);
  --nd-on-accent:   #fff;                                 /* text/icon on an accent fill */
  --nd-danger:      #ef4444;                              /* notification dot */
  --nd-avatar:      linear-gradient(135deg, #334155, #475569);  /* org/user avatar */
  --nav-h:          56px;
  --nav-radius:     14px;

  /* ── Shared APP surface palette (books + dispatch bodies) ──────────────────
     books.css (--inv-*) and scheduler.css (--bg/--surface/--card/…) hand-rolled
     two palettes that already resolve to identical values in BOTH themes (only
     the accent-HOVER shade differs, which stays app-local). Consolidating them
     here makes one place drive both apps' chrome — the foundation for a future
     restyle (change a value once, both apps move). Values are byte-identical to
     what each app renders today, so repointing the books and dispatch tokens at
     these is a no-op. NOTE: --nd- (nav) is deliberately NOT merged — it diverges on
     hairlines (rgba vs solid) and danger (#ef4444 vs #ff5c5c); reconciling nav
     with the app bodies is a visual decision deferred to the restyle phase. */
  --app-bg:          #0e0f12;
  --app-surface:     #15171c;
  --app-sunken:      #15171c;   /* inputs / secondary bg (books --inv-input-bg, sched --bg-secondary) */
  --app-card:        #1b1e25;
  --app-card-hover:  #23272f;
  --app-border:      #2c313b;
  --app-border-2:    #3a404c;
  --app-text:        #f2f4f8;
  --app-text-2:      #aeb6c4;
  --app-muted:       #8b95a7;
  --app-accent:      #33b1ff;
  --app-accent-fg:   #04121d;   /* readable ink for text/icons sitting ON the accent */
  --app-danger:      #ff5c5c;   /* warm return-air red (both apps; distinct from --nd-danger) */

  /* Status + accent derivatives. These were referenced ~73 times across the
     books pages while being defined NOWHERE, so every one of them silently fell
     through to its hardcoded dark literal — including in the light theme, where
     several land near 1.7:1 on white. Declaring them here is what makes the
     light block below able to correct them. */
  --app-ok:          #22c55e;
  --app-warn:        #f59e0b;
  --app-bad:         #ef4444;
  --app-accent-h:    #5cc2ff;   /* accent, hover state */
  --app-accent-soft: rgba(51,177,255,.12);
  --app-accent-edge: rgba(51,177,255,.35);
  --app-danger-soft: rgba(255,92,92,.12);
  --app-violet:      #a78bfa;
  --app-teal:        #2dd4bf;

  /* ── Plenum primitives (radii · motion · elevation) ───────────────────────
     Copied verbatim from hub/css/plenum.css so the three apps share ONE shape,
     motion and shadow language. ADDITIVE ONLY — nothing in books/scheduler
     references these yet, so they render nothing today; they are the vocabulary
     the Phase-2 restyle draws from (replacing each app's ad-hoc radii, easings
     and hard-coded box-shadows). Radii + easings are theme-independent; the
     ink-tinted shadows below are the DARK set, with a light set in the light
     block. Pair motion with the existing --dur-2 / --dur-3 duration scale. */
  /* PAPER. A printed document is not themed: it is ink on white, whatever the
     app looks like on screen. Kept here rather than as literals in a page's
     stylesheet because that is the rule — colours are defined in tokens.css and
     nowhere else — and because "what does our paper look like" is a decision
     worth having one answer to when a second document starts printing. */
  --print-paper: #ffffff;
  --print-ink:   #111111;
  --print-ink-2: #555555;
  --print-rule:  #bbbbbb;
  --print-biz:   #0b6fb8;   /* the accent, darkened until it survives a photocopy */
  --print-priv:  #777777;
  --print-unk:   #b4740f;

  --r-xs: 7px;
  --r:    11px;
  --r-lg: 15px;
  --r-xl: 19px;
  /* MOTION, AND WHICH CURVE GOES WHERE.
     --spring overshoots: it travels ~9% past its target at 62% of the way
     through and comes back. That is right for something ARRIVING FROM NOTHING —
     a menu, a modal, a toast — where a little past-the-mark reads as "it
     landed". Use it on `animation` only.
     It is WRONG for a hover lift, and that is where it used to live. Nothing has
     been launched: the pointer is sitting still on the card, and the card rises,
     retreats a little and settles under a finger that never moved. All 22
     transform transitions moved to --out on 2026-08-23; the 9 pop animations
     kept --spring. If you are reaching for a curve for a `transition`, it is
     this one below. */
  --spring: cubic-bezier(.34, 1.32, .52, 1);   /* overshoot — menu/modal/toast POP only */
  --out:    cubic-bezier(.22, .61, .36, 1);    /* smooth entry — every transition */
  --sh-1:   0 1px 2px rgba(0,0,0,.42);
  --sh-2:   0 1px 2px rgba(0,0,0,.42), 0 12px 28px -8px rgba(0,0,0,.55), 0 28px 50px -22px rgba(0,0,0,.6);
  --sh-pop: 0 18px 46px -12px rgba(0,0,0,.66);

  /* Plenum fixed type scale (verbatim from plenum.css). Additive — a stable
     modular ladder for UI text, distinct from the fluid --text-* clamp scale
     used for hero/marketing headings. Consumed by the Books restyle (Phase 2). */
  --t-micro: 10.5px; --t-xs: 11.5px; --t-sm: 12.5px; --t-base: 13.5px;
  --t-md: 15px; --t-lg: 17px; --t-xl: 20px; --t-2xl: 25px; --t-3xl: 33px;
}

/* No-flash guard for the unified nav: when a prior load cached the flag ON, the
   per-page pre-paint script adds html.nd-nav-pending, hiding the legacy bar
   BEFORE it paints so it never flashes while nav.js swaps in the unified bar.
   Specificity (0,2,1) beats .topbar.books-bar / .sched-topbar without !important. */
html.nd-nav-pending .books-bar,
html.nd-nav-pending .sched-topbar { display: none; }

/* Same idea for the customer document pages (js/core/doc-canon.js). The rail is
   drawn one way by customer-doc.css and another by customer-doc-v2.css, and
   which one applies is a flag read away — so it is held back rather than
   painted twice. `visibility` not `display`, because the space it occupies is
   the same either way and collapsing it would jump the page. doc-canon.js
   clears the class on a timer whatever the read does, so a customer on a dead
   network gets the v1 rail rather than a headless page. */
html.cdoc-pending .page-header { visibility: hidden; }

/* Light-theme values for the unified nav tokens. One selector list covers all
   three apps' distinct light triggers (hub uses [data-theme], books
   [data-books-theme], dispatch html.sched-light) so the shared bar flips with
   whichever app it sits in. Values are byte-identical to each app's existing
   light palette, so this is invisible until the component ships. */
:root[data-theme="light"],
:root[data-books-theme="light"],
html.sched-light {
  --nd-bg:          #f4f6fa;
  --nd-surface:     #ffffff;
  --nd-card:        #ffffff;
  --nd-card-hover:  #eef2f7;
  --nd-text:        #131722;
  --nd-text-2:      #4a5568;
  --nd-muted:       #8a94a4;
  --nd-accent:      #1493e6;
  --nd-accent-soft: rgba(20,147,230,.12);
  --nd-accent-edge: rgba(20,147,230,.4);
  --nd-hair:        rgba(15,23,42,.09);
  --nd-hair-2:      rgba(15,23,42,.14);

  /* Shared APP surface palette — light. Byte-identical to books + dispatch
     light blocks (which themselves already matched each other exactly). */
  --app-bg:          #f4f6fa;
  --app-surface:     #ffffff;
  --app-sunken:      #e9edf3;
  --app-card:        #ffffff;
  --app-card-hover:  #eef2f7;
  --app-border:      #e4e9f1;
  --app-border-2:    #d5dce8;
  --app-text:        #131722;
  --app-text-2:      #4a5568;
  --app-muted:       #6b7686;
  --app-accent:      #1493e6;
  --app-accent-fg:   #ffffff;   /* white reads cleanly on the light-theme medium-blue accent (AA for bold) */
  --app-danger:      #e23b3b;

  /* Status derivatives — re-darkened for white. The dark-theme pastels are
     1.6–2.2:1 here, and they carry real numbers (amounts owing, sync state),
     not decoration. These clear the 4.5:1 AA floor for body text. */
  --app-ok:          #15803d;
  --app-warn:        #b45309;
  --app-bad:         #dc2626;
  --app-accent-h:    #0f7cc4;
  --app-accent-soft: rgba(20,147,230,.10);
  --app-accent-edge: rgba(20,147,230,.32);
  --app-danger-soft: rgba(226,59,59,.10);
  --app-violet:      #6d28d9;
  --app-teal:        #0f766e;

  /* Plenum elevation — light set (warm ink-tinted, no pure black). Radii and
     easings above are theme-independent, so only the shadows re-derive here. */
  --sh-1:   0 1px 1.5px rgba(28,26,22,.05), 0 1px 2px rgba(28,26,22,.03);
  --sh-2:   0 1px 2px rgba(28,26,22,.05), 0 10px 20px -8px rgba(28,26,22,.11), 0 22px 38px -20px rgba(28,26,22,.12);
  --sh-pop: 0 2px 8px rgba(28,26,22,.07), 0 22px 50px -14px rgba(28,26,22,.24);
}

/* ── Breakpoint ladder (reference only) ──────────────────────────────────────
   CSS custom properties cannot be read inside `@media` conditions, so these
   are not literal vars to consume — they are the canonical pixel values every
   stylesheet should match when writing a media query, instead of each file
   picking its own nearby number. Match the existing usage already converged
   on in dashboard.css / ui-next.css:
     --bp-laptop : 1200px  (dense desktop grids start losing readability)
     --bp-tablet :  768px  (primary mobile breakpoint — drawer nav, stacked grids)
     --bp-phone  :  480px  (phone — collapse remaining multi-col grids to 1)
   New responsive CSS should reuse 1200 / 768 / 480 rather than introducing a
   fourth nearby value (e.g. 800px, 720px, 760px). */

/* ── Global defensive defaults ───────────────────────────────────────────────
   Cheap, safe rules that stop the most common "extreme content" breakages
   (long unbroken strings, oversized media, runaway localized labels) WITHOUT
   touching component layout. We intentionally do NOT set a blanket
   `* { min-width: 0 }` (that would silently change flex behaviour app-wide);
   instead we provide the opt-in `.u-min0` utility below for flex children that
   need to be allowed to shrink. */
img, svg, video, canvas { max-width: 100%; }
/* Headings/paragraphs should wrap mid-word rather than overflow their box. */
h1, h2, h3, h4, h5, h6, p, li, dd, dt, figcaption { overflow-wrap: break-word; }

/* ── Accessible-motion guard ─────────────────────────────────────────────────
   Honour the OS "reduce motion" setting globally. Animations collapse to a
   single near-instant frame instead of being removed (so state still changes
   visibly), and smooth-scroll is disabled. Individual primitives ALSO check
   this in JS, but the CSS guard covers any animated element we miss. */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
  /* Functional loading spinners are exempt: a frozen single frame reads as
     "stuck/broken", whereas a steady rotation (no flashing) conveys ongoing
     work and is the accepted reduced-motion exception. Covers .spin/.ti-spin,
     the login .spinner, Tabler ti-loader icons, and inline animation:spin. */
  .spin, .ti-spin, .spinner,
  [class*="ti-loader"], [style*="spin"] {
    animation-duration: 0.8s !important;
    animation-iteration-count: infinite !important;
  }
}

/* ── Defensive layout utilities (opt-in) ─────────────────────────────────────
   Reach for these instead of re-writing the same truncation/wrap rules inline.
   They are the antidote to text clipping under long strings / localization. */
.u-min0      { min-width: 0; min-height: 0; }        /* let a flex/grid child shrink */
.u-truncate  { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.u-clamp-1,
.u-clamp-2,
.u-clamp-3   { display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden; }
.u-clamp-1   { -webkit-line-clamp: 1; }
.u-clamp-2   { -webkit-line-clamp: 2; }
.u-clamp-3   { -webkit-line-clamp: 3; }
.u-break     { overflow-wrap: anywhere; word-break: break-word; }
.u-nowrap    { white-space: nowrap; }

/* A visually-hidden helper for screen-reader-only labels (used by the new
   a11y primitives when a control has only an icon). */
.sr-only {
  position: absolute !important; width: 1px; height: 1px;
  padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0);
  white-space: nowrap; border: 0;
}

/* Skip link: visually hidden until focused, then pinned to the top-left so
   keyboard users can jump straight past the chrome to the main content. */
.skip-link {
  position: absolute; left: -9999px; top: 0; z-index: var(--z-max, 9999);
  padding: var(--sp-2_5) var(--sp-4); background: var(--bg-elevated, #111827); color: #fff;
  border-radius: 0 0 8px 0; font-size: var(--text-md, 14px); font-weight: 600;
  text-decoration: none;
}
.skip-link:focus { left: 0; outline: 2px solid #fff; outline-offset: 2px; }

/* Baseline keyboard-focus indicator for every page (some pages — e.g. the
   dashboard — don't load style.css's global rule, so their buttons and the
   role="button" elements upgraded by core/clickable.js had no visible ring).
   Zero specificity via :where() so any existing component focus style wins.
   :focus-visible matches keyboard/AT focus only, never a mouse click. */
:where(a, button, [role="button"], [role="tab"], [role="link"],
       input, select, textarea, summary, [tabindex]):focus-visible {
  outline: var(--focus-ring);
  outline-offset: var(--focus-offset);
}

/* ── Shared dialog (core/dialog.js) — theme bridge ───────────────────────────
   dialog.js styles itself from a GENERIC var vocabulary (--surface, --text,
   --accent, --border, --input-bg …) that no app stylesheet ever defined, so
   every confirm and prompt fell through to that component's light-theme
   literals: a #fff box with #1e293b text and a #2563eb button, floating on top
   of the dark app. That is the surface used to Finalize & send, Mark as paid,
   Void an invoice and Delete a draft — i.e. every consequential action.

   Bridged here rather than renaming the vars inside dialog.js so the component
   stays drop-in on pages that have no token layer at all, and scoped to the
   overlay so these very generic names cannot leak into anything else. Custom
   properties inherit, so declaring them on the overlay reaches the whole box. */
.nd-dialog-overlay {
  --bg-modal:      var(--app-card);
  --surface:       var(--app-card);
  --card:          var(--app-card);
  --text:          var(--app-text);
  --text-primary:  var(--app-text);
  --text-muted:    var(--app-text-2);
  --border:        var(--app-border);
  --border-focus:  var(--app-accent);
  --input-bg:      var(--app-sunken);
  --surface-hover: var(--app-card-hover);
  --bg-secondary:  var(--app-card-hover);
  --accent:        var(--app-accent);
  --accent-hover:  var(--app-accent-h);
  --danger:        var(--app-danger);
  --radius-xl:     var(--r-lg);
  --shadow-lg:     var(--sh-pop);
}
/* dialog.js hardcodes `color:#fff` on the confirm button, which is only ~2.2:1
   on the dark theme's light-blue accent. --app-accent-fg is the token that
   exists for exactly this. Needs one extra class of specificity to beat the
   component's own rule, which is injected into <head> after this file. */
.nd-dialog-overlay .nd-dialog-box .nd-dlg-ok { color: var(--app-accent-fg); }
.nd-dialog-overlay .nd-dialog-box .nd-dlg-ok.nd-dlg-danger { color: #fff; }

/* ─────────────────────────────────────────────────────────────────────────
   Books canon (html.books-v4, feature_books_canon) — ported from the mockup
   public/_books-canon-live-rail.html. Declared here because stylelint only
   allows colour literals in tokens.css / theme-*.css. Every block is gated
   on .books-v4, so all of it is inert unless that flag is on.
   ───────────────────────────────────────────────────────────────────────── */
:root.books-v4 {
  --sb-w: 232px;
  --r-2xs: 2px; --r-xs: 3px; --r-sm: 4px; --r: 6px; --r-lg: 10px; --r-pill: 999px;
  --sp-1: 4px;  --sp-2: 8px;  --sp-3: 12px; --sp-4: 16px;
  --sp-5: 20px; --sp-6: 24px; --sp-8: 32px; --sp-10: 40px;

  --face: 'Sora', system-ui, -apple-system, sans-serif;
  --num:  'Inter', system-ui, sans-serif;   /* Sora has no tabular numerals */

  --t-2xs: 10px; --t-xs: 11px; --t-sm: 12px; --t-base: 13px;
  --t-md: 14px;  --t-lg: 15px; --t-xl: 17px; --t-2xl: 20px;
  --t-fig: 26px;
  --t-fig-lg: 32px;  /* the single figure a screen is about */
  --icon-lg: 30px;   /* empty-state icon; not a type step */

  --ease: cubic-bezier(.2,.8,.25,1);
  --dur: .18s;
}
:root.books-v4 {
  --bg: var(--app-bg);            --surface: var(--app-surface);
  --card: var(--app-card);        --card-2: var(--app-card-hover);
  --sunken: var(--app-sunken);    --border: var(--app-border);
  --border-2: var(--app-border-2);
  --text: var(--app-text);        --text-2: var(--app-text-2);
  --muted: var(--app-muted);
  --accent: var(--app-accent);    --accent-h: var(--app-accent-h);
  --accent-fg: var(--app-accent-fg);
  --accent-soft: var(--app-accent-soft); --accent-edge: var(--app-accent-edge);
  --ok: var(--app-ok);            --warn: var(--app-warn);
  --danger: var(--app-danger);
  --danger-soft: color-mix(in srgb, var(--app-danger) 12%, transparent);
  --shadow: 0 1px 2px rgba(0,0,0,.4), 0 8px 24px -12px rgba(0,0,0,.55);
}
:root.books-v4[data-theme="dark"] {
  --danger-fg: #2a0808;
  --c-paid: #12a150; --c-await: #1c96e0; --c-over: #ef4444; --c-draft: #4a515e;
  --c-inv: #1c96e0;  --c-coll: #12a150;
  /* Lateness: one hue, monotone OKLCH lightness .571/.612/.691, measured
     against --app-sunken #15171c — 3.76 / 4.37 / 5.92:1. */
  --age-0: #1c96e0;
  --age-1: #bd4f52; --age-2: #d94d4d; --age-3: #ff5c5c;
  --placeholder: #8b95a7;   /* 6.10:1 on --app-sunken */
  --grid: rgba(255,255,255,.055);
  --axis: rgba(255,255,255,.13);
}
:root.books-v4[data-theme="light"] {
  --danger-fg: #ffffff;
  /* tokens.css ships --app-ok #15803d, which is 4.27:1 on --app-sunken — under
     AA for the "Paid" and "+$x" labels that sit on it. Darkened a step at the
     same chroma. The chart fills go the other way, see --c-paid. */
  --ok: #0f7a38;
  --shadow: 0 1px 2px rgba(16,24,40,.05), 0 8px 24px -14px rgba(16,24,40,.18);
  --c-paid: #1a9e4b; --c-await: #1493e6; --c-over: #dc2626; --c-draft: #c3cad6;
  --c-inv: #1493e6;  --c-coll: #1a9e4b;
  /* Light runs the other way — darker is worse. 4.08 / 5.70 / 8.10:1 on
     --app-sunken #e9edf3. The previous palest step measured 2.10:1. */
  --age-0: #0f7cc4;
  --age-1: #d13a3a; --age-2: #b02525; --age-3: #8a1616;
  --placeholder: #5c6675;   /* 4.95:1; --app-muted is only 3.92:1 here */
  --grid: rgba(16,24,40,.06);
  --axis: rgba(16,24,40,.14);
}

/* ═════════════════════════════════════════════════════════════════════════
   CUSTOMER DOCUMENTS (html.cdoc-v2 → css/customer-doc-v2.css).

   The invoice / quote / variation / agreement pages a customer opens from an
   email or the portal, restyled onto the Books canon. Declared here because
   stylelint only allows colour literals in tokens.css; every name is gated on
   .cdoc-v2, so with feature_portal_v2 off none of it applies and the shipped
   customer-doc.css renders exactly as before.

   WHY THESE ARE LITERALS AND NOT var(--app-*). The --app-* family is DARK at
   bare :root and only turns light under [data-theme="light"] — a stamp that
   NDTheme owns and writes from a cookie. These pages have no signed-in user,
   no stored preference and no business reading the contractor's cookie, so
   they must follow the DEVICE and nothing else. Stamping data-theme here from
   a hand-rolled pre-paint reader is exactly what the theme contract forbids,
   so the split is a media query and the values are spelled out. They are the
   same Airflow values the canon resolves to; keep them in step by hand, the
   way hub/css/plenum.css already does.

   THE LIGHT BLOCK IS COMPLETE ON PURPOSE. Every token is declared here and
   only REdefined under the media query, so a viewer with no preference (the
   common case) still resolves every name. scripts/check-page-tokens.js cannot
   see inside an @media block, and neither can a browser that never matches it.
   ═════════════════════════════════════════════════════════════════════════ */
:root.cdoc-v2 {
  /* Shape and rhythm — books-v4's own, verbatim (see :root.books-v4 above). */
  --cd-r-sm: 4px; --cd-r: 6px; --cd-r-lg: 10px; --cd-r-pill: 999px;
  --cd-sp-1: 4px;  --cd-sp-2: 8px;  --cd-sp-3: 12px; --cd-sp-4: 16px;
  --cd-sp-5: 20px; --cd-sp-6: 24px; --cd-sp-8: 32px;

  --cd-face: 'Sora', system-ui, -apple-system, sans-serif;
  --cd-num:  'Inter', system-ui, sans-serif;   /* Sora has no tabular numerals */

  --cd-t-2xs: 10px; --cd-t-xs: 11px; --cd-t-sm: 12px; --cd-t-base: 13px;
  --cd-t-md: 14px;  --cd-t-lg: 15px; --cd-t-xl: 17px; --cd-t-2xl: 20px;
  --cd-t-fig: 26px;
  --cd-t-fig-lg: 32px;   /* the single figure the page is about */

  --cd-ease: cubic-bezier(.2,.8,.25,1);
  --cd-dur: .18s;

  /* Surfaces — Airflow light. */
  --cd-bg: #f4f6fa;
  --cd-card: #ffffff;
  --cd-card-2: #eef2f7;
  --cd-sunken: #e9edf3;
  --cd-text: #131722;
  --cd-text-2: #4a5568;
  --cd-muted: #6b7686;
  --cd-border: #e4e9f1;
  --cd-border-2: #d5dce8;
  --cd-shadow: 0 1px 2px rgba(16,24,40,.05), 0 8px 24px -14px rgba(16,24,40,.18);

  /* State. --cd-ok is the canon's darkened light-theme green (#15803d measures
     4.27:1 on --cd-sunken, under AA for the "Paid" pill that sits on it). */
  --cd-ok: #0f7a38;
  --cd-warn: #b45309;
  --cd-danger: #e23b3b;
  /* Accepting a quote is a green fill, so --cd-ok needs a hover and an ink of
     its own — the only state colour here that is ever a fill rather than text. */
  --cd-ok-h: #0d682f;
  --cd-ok-fg: #ffffff;

  /* THE SELLER'S BRAND. js/core/doc-canon.js overwrites --cd-brand and its two
     derivatives inline from profiles.business_profile.brand_color, contrast-
     corrected for the ground actually in use. These are the fallbacks for a
     contractor who never set one: the page is then unbranded, never unstyled. */
  --cd-brand: #1493e6;
  --cd-brand-h: #0f76bf;
  --cd-brand-fg: #ffffff;

  /* Stripe's own purple. The Pay button is the PROCESSOR's control, not the
     contractor's, and it keeps the colour customers recognise on a checkout. */
  --cd-stripe: #635bff;
  --cd-stripe-h: #554df0;
  --cd-stripe-fg: #ffffff;
}
/* DARK, TWO WAYS, and the order of authority is the point.

   1. The device preference — but only while nobody has said otherwise. A
      customer who opened an emailed invoice has no account and no choice to
      honour, so the device is the best answer available.
   2. An explicit choice, below. The customer portal has a light/dark toggle
      and NDTheme carries the answer in the nd_theme cookie; before this the
      document pages never read it, so setting the portal to light and then
      opening a quote gave you a dark quote. The same flag that keeps the two
      surfaces on one design language left them free to disagree about
      brightness.

   The media block is kept even though NDTheme stamps data-theme on every one
   of these pages now: if that script does not run — CSP, a parse error, JS
   off — the page still follows the device instead of being stuck light.

   :root is a PSEUDO-CLASS, so the base block is (0,2,0) and both of these
   are (0,4,0). Neither can lose to it whatever the order. */
@media screen and (prefers-color-scheme: dark) {
  :root.cdoc-v2:not([data-theme="light"]):not(.cdoc-light) {
    --cd-bg: #0e0f12;
    --cd-card: #1b1e25;
    --cd-card-2: #23272f;
    --cd-sunken: #15171c;
    --cd-text: #f2f4f8;
    --cd-text-2: #aeb6c4;
    --cd-muted: #8b95a7;
    --cd-border: #2c313b;
    --cd-border-2: #3a404c;
    --cd-shadow: 0 1px 2px rgba(0,0,0,.4), 0 8px 24px -12px rgba(0,0,0,.55);

    --cd-ok: #22c55e;
    --cd-warn: #f59e0b;
    --cd-danger: #ff5c5c;
    /* The dark green is bright enough that white on it fails; it takes dark ink
       and its hover goes lighter, not darker, like every other dark fill. */
    --cd-ok-h: #4ad47f;
    --cd-ok-fg: #052e14;

    --cd-brand: #33b1ff;
    --cd-brand-h: #5cc2ff;
    --cd-brand-fg: #04121d;

    /* Stripe purple is 2.4:1 on the dark card — the wordmark inside it stops
       being legible. Lifted to the tint Stripe itself uses on dark surfaces. */
    --cd-stripe: #8b85ff;
    --cd-stripe-h: #a29dff;
    --cd-stripe-fg: #0b0a2a;
  }
}
/* The same values for someone who chose dark on a light device. Byte-identical
   to the block above by construction, and test/unit/customer-page-theme keeps
   them that way — a palette that drifts between "dark because the phone is"
   and "dark because you asked" is two designs wearing one name. */
@media screen {
:root.cdoc-v2[data-theme="dark"]:not(.cdoc-light) {
    --cd-bg: #0e0f12;
    --cd-card: #1b1e25;
    --cd-card-2: #23272f;
    --cd-sunken: #15171c;
    --cd-text: #f2f4f8;
    --cd-text-2: #aeb6c4;
    --cd-muted: #8b95a7;
    --cd-border: #2c313b;
    --cd-border-2: #3a404c;
    --cd-shadow: 0 1px 2px rgba(0,0,0,.4), 0 8px 24px -12px rgba(0,0,0,.55);

    --cd-ok: #22c55e;
    --cd-warn: #f59e0b;
    --cd-danger: #ff5c5c;
    /* The dark green is bright enough that white on it fails; it takes dark ink
       and its hover goes lighter, not darker, like every other dark fill. */
    --cd-ok-h: #4ad47f;
    --cd-ok-fg: #052e14;

    --cd-brand: #33b1ff;
    --cd-brand-h: #5cc2ff;
    --cd-brand-fg: #04121d;

    /* Stripe purple is 2.4:1 on the dark card — the wordmark inside it stops
       being legible. Lifted to the tint Stripe itself uses on dark surfaces. */
    --cd-stripe: #8b85ff;
    --cd-stripe-h: #a29dff;
    --cd-stripe-fg: #0b0a2a;
}
}
/* ── html.cdoc-light — a canon page that must not follow anything ─────────
   report.html only, and only until core/reportview.js is tokenised. That
   renderer injects its own stylesheet holding thirteen token names and about a
   hundred literal slate colours; the thirteen follow the canon, the hundred
   cannot, so a dark ground would put #e2e8f0 hairlines and #0f172a headings on
   #0e0f12. The page renders light today — this keeps that, rather than shipping
   a half-dark document to a customer who has to read it.

   It declares NOTHING. Both dark blocks above exclude .cdoc-light, so the base
   light set simply stands — which beats re-stating twenty values that then have
   to be kept in step with the block they were copied from. The pin outranks an
   explicit dark choice as well as the device, deliberately: a customer asking
   for dark on this one page would get a document they cannot read.

   js/core/doc-canon.js reads this class too — isDark() answers false for a
   pinned page, or the contractor's brand is fitted to a ground the page is not
   on. Its colour-scheme sits with the other two in customer-doc-v2.css §1. */

/* Print is paper. Both dark blocks above are `screen`-scoped, so nothing dark
   reaches this in the first place — @media print does NOT turn
   prefers-color-scheme off, and the two used to match at once and be settled by
   source order alone. This stays as the guard on the next one somebody adds. */
@media print {
  :root.cdoc-v2 {
    --cd-bg: #ffffff;
    --cd-card: #ffffff;
    --cd-card-2: #f4f6fa;
    --cd-sunken: #f4f6fa;
    --cd-text: #131722;
    --cd-text-2: #4a5568;
    --cd-muted: #6b7686;
    --cd-border: #e4e9f1;
    --cd-border-2: #d5dce8;
    --cd-shadow: none;
    --cd-ok: #0f7a38;
    --cd-ok-h: #0d682f;
    --cd-ok-fg: #ffffff;
    --cd-warn: #b45309;
    --cd-danger: #e23b3b;
    --cd-stripe: #635bff;
    --cd-stripe-fg: #ffffff;
  }
}

/* ══════════════════════════════════════════════════════════════════════════
   THE CANON RAIL'S TOKEN LAYER (feature_rail_canon → css/rail-canon.css).

   rail-canon.css was authored against hub/css/plenum.css's Airflow palette,
   but the rail mounts on Books and Dispatch, which load THIS file and never
   load plenum. Seventeen of the names it needs (--paper, --panel, --ink,
   --hair, --hot, --font, --mono, --track-*, --t-*) do not exist here, so the
   rail would have rendered with undefined custom properties on every real
   page — no ground, no hairlines, no text colour.

   Scoped to `.nr` rather than :root: these are plenum's values, and letting
   them reach the page would re-tint two apps that have their own palettes.
   Values copied verbatim from hub/css/plenum.css — if that file's palette
   moves, this moves with it.
   ══════════════════════════════════════════════════════════════════════════ */
.nr {
  --paper: #f4f6fa; --panel: #ffffff; --panel-2: #eef2f7; --sunken: #e9edf3;
  --ink: #131722; --ink-2: #4a5568; --ink-3: #6b7585;
  --hair: #e4e9f1; --hair-2: #d5dce8;
  --accent: #1493e6; --accent-2: #0f76bf; --accent-wash: #dcecfb;
  --accent-ring: rgba(20, 147, 230, .30);
  --hot: #e23b3b;
  --hot-soft: color-mix(in srgb, #e23b3b 12%, transparent);
  --font: 'Sora', system-ui, -apple-system, sans-serif;
  --mono: 'JetBrains Mono', ui-monospace, 'SF Mono', monospace;
  --t-micro: 10.5px; --t-xs: 11.5px; --t-sm: 12.5px; --t-base: 13.5px;
  --t-md: 15px; --t-lg: 17px; --t-xl: 20px; --t-2xl: 25px;
  --track-cap: .145em; --track-tight: -.021em; --track-snug: -.012em;
  --out: cubic-bezier(.22, .61, .36, 1); --dur: .19s;
  --sh-1: 0 1px 1.5px rgba(28, 26, 22, .05), 0 1px 2px rgba(28, 26, 22, .03);
  --sh-2: 0 1px 2px rgba(28, 26, 22, .05), 0 10px 20px -8px rgba(28, 26, 22, .11), 0 22px 38px -20px rgba(28, 26, 22, .12);

  /* Accent ink ON --accent-wash. plenum's --accent-2 over the light wash is
     4.00:1 — under AA — and it is the colour every "you are here" row uses. */
  --nr-on-wash: #0b5f9b;
  /* The rail's one filled control. plenum's .btn-accent is #fff on --accent:
     3.28:1 light, 2.37:1 dark; both fail. Same fix auth.css's .btn-primary made. */
  --nr-cta: #0f76bf; --nr-cta-hover: #0b5f9b; --nr-cta-ink: #ffffff;
  --nr-on-accent: #0e0f12;      /* ink that sits ON an accent fill */
  --nr-danger-ink: #b32626;     /* --hot as TEXT on a light panel is 4.27:1 */
}
:root[data-theme="dark"] .nr {
  --paper: #0e0f12; --panel: #1b1e25; --panel-2: #23272f; --sunken: #15171c;
  --ink: #f2f4f8; --ink-2: #aeb6c4; --ink-3: #8b95a7;
  --hair: #2c313b; --hair-2: #3a404c;
  --accent: #33b1ff; --accent-2: #5cc0ff; --accent-wash: #0a2233;
  --accent-ring: rgba(51, 177, 255, .32);
  --hot: #ff5c5c;
  --hot-soft: color-mix(in srgb, #ff5c5c 12%, transparent);
  --sh-1: 0 1px 2px rgba(0, 0, 0, .42);
  --sh-2: 0 1px 2px rgba(0, 0, 0, .42), 0 12px 28px -8px rgba(0, 0, 0, .55), 0 28px 50px -22px rgba(0, 0, 0, .6);
  --nr-on-wash: var(--accent-2);
  --nr-cta: var(--accent); --nr-cta-hover: var(--accent-2); --nr-cta-ink: #0e0f12;
  --nr-danger-ink: var(--hot);
}

/* ══════════════════════════════════════════════════════════════════════════
   THE CANVAS DASHBOARD'S TOKEN LAYER (feature_canvas_canon → css/canvas-canon.css).

   Same situation as the rail directly above, one scope wider. canvas-canon.css
   was authored against hub/css/plenum.css; dashboard.html loads THIS file and
   dashboard.css, which name their surfaces differently. The rail could scope
   plenum's palette to `.nr` because only the bar needed it — here the PAGE is
   what is being restyled, so it is scoped to html.canvas-v2 and dashboard.css's
   own names are remapped onto it. That remap is what carries the eleven
   sections the port does not repaint: they keep their markup and their
   handlers and simply stop being a second visual world on the same page.

   Values copied verbatim from hub/css/plenum.css — if that palette moves, the
   .nr block above and this one move with it.

   --d-* is the DRAWING legend (cold blue supply, hot red return, graphite
   exhaust). --c-* is the CHART, and in dark it is deliberately a darker step
   of the same hue: a chart mark is a filled block big enough to glare, a duct
   run is a 2px line on a sheet. Dark mode is selected, never flipped. Both
   sets were validated with the dataviz palette validator, not judged by eye.
   ══════════════════════════════════════════════════════════════════════════ */
html.canvas-v2 {
  /* plenum's Airflow palette, light. Copied verbatim from hub/css/plenum.css
     via tokens.css's .nr block — if that palette moves, both move with it. */
  --paper: #f4f6fa; --panel: #ffffff; --panel-2: #eef2f7; --sunken: #e9edf3;
  --ink: #131722; --ink-2: #4a5568; --ink-3: #6b7585;
  --hair: #e4e9f1; --hair-2: #d5dce8;
  --accent: #1493e6; --accent-2: #0f76bf; --accent-wash: #dcecfb;
  --accent-ring: rgba(20, 147, 230, .30);
  --hot: #e23b3b; --hot-soft: color-mix(in srgb, #e23b3b 12%, transparent);
  --font: 'Sora', system-ui, -apple-system, sans-serif;
  --mono: 'JetBrains Mono', ui-monospace, 'SF Mono', monospace;
  --num: 'Inter', system-ui, sans-serif;
  --spring: cubic-bezier(.34, 1.32, .52, 1);
  --r-xs: 7px; --r-xl: 19px;

  /* MOTION AND TRACKING, WHICH THIS BLOCK USED TO OMIT. --out, --sh-* and --r-lg
     resolve from :root, but --dur and --track-* are declared only inside
     :root.books-v4 and .nr — so on this page every `transition: … var(--dur)` and
     every `letter-spacing: var(--track-snug)` in canvas-canon.css was invalid at
     computed-value time and silently dropped. An undefined custom property is not
     a fallback; the declaration is simply thrown away. That cost the port all its
     transitions and all its optical tracking, invisibly. */
  --dur: .19s;
  --track-cap: .145em; --track-tight: -.021em; --track-snug: -.012em;

  /* the drawing legend and the chart steps (see THE TOKEN BLOCK, top of file) */
  --d-sup: #1493e6; --d-ret: #e23b3b; --d-exh: #6b7585; --d-fresh: #b45309;
  --d-ink: #131722; --d-thin: #9aa4b5; --d-sheet: #ffffff; --d-uncond: #eef2f7;
  --d-room: #4a5568;
  --c-sup: #1493e6; --c-ret: #e23b3b; --c-exh: #6b7585; --c-fresh: #b45309;

  /* THE RAIL'S OWN FIVE, REPEATED — and this one is a trap worth naming. The
     block above scopes them to `.nr`, which is right for a rail sitting on a
     page with its own palette. canvas-canon.css spends them on the PAGE: the
     one filled control (.cd-btn.primary), the accent ink on a wash, and the
     danger ink. Scoped to .nr they resolve to nothing outside the bar, and an
     invalid custom property is not a fallback — the filled button renders with
     no ground at all and the danger word renders as body ink. Both did. */
  --nr-on-wash: #0b5f9b;
  --nr-cta: #0f76bf; --nr-cta-hover: #0b5f9b; --nr-cta-ink: #ffffff;
  --nr-on-accent: #0e0f12; --nr-danger-ink: #b32626;

  /* dashboard.css's own names, remapped. This is what restyles the eleven
     sections this port does not repaint — they keep their markup and their
     handlers and simply stop being a second visual world on the same page. */
  --bg: var(--paper); --surface: var(--panel); --card: var(--panel); --elev-3: var(--panel-2);
  --bg-secondary: var(--sunken); --bg-card: var(--panel); --bg-sidebar: var(--panel);
  --border: var(--hair); --border-strong: var(--hair-2);
  --text: var(--ink); --text-primary: var(--ink); --text-secondary: var(--ink-2); --text-muted: var(--ink-2);
  --accent-hover: var(--accent-2);
  --accent-soft: var(--accent-wash); --accent-edge: color-mix(in srgb, var(--accent) 34%, transparent);
  --accent-line: var(--accent);
  --danger: var(--hot); --radius: var(--r);
  --supply: var(--d-sup); --return-color: var(--d-ret); --fresh: var(--d-fresh); --exhaust: var(--d-exh);
  /* Airflow is FLAT: no inset top-light edge, no coloured glow. PRODUCT.md
     names both as the thing that reads "AI-coded". */
  --edge-light: none;
  --shadow-1: var(--sh-1); --shadow-2: var(--sh-2); --shadow-3: var(--sh-pop);
  --card-shadow: var(--sh-1); --card-shadow-hover: var(--sh-2);
  --glow-blue: none; --glow-purple: none; --glow-green: none; --glow-amber: none; --glow-red: none;
  color-scheme: light;
}

html.canvas-v2[data-theme="dark"] {
  --paper: #0e0f12; --panel: #1b1e25; --panel-2: #23272f; --sunken: #15171c;
  --ink: #f2f4f8; --ink-2: #aeb6c4; --ink-3: #8b95a7;
  --hair: #2c313b; --hair-2: #3a404c;
  --accent: #33b1ff; --accent-2: #5cc0ff; --accent-wash: #0a2233;
  --accent-ring: rgba(51, 177, 255, .32);
  --hot: #ff5c5c; --hot-soft: color-mix(in srgb, #ff5c5c 12%, transparent);
  --d-sup: #33b1ff; --d-ret: #ff5c5c; --d-exh: #8b95a7; --d-fresh: #f5a623;
  --d-ink: #e6eaf2; --d-thin: #5d6674; --d-sheet: #15171c; --d-uncond: #1b1e25;
  --d-room: #aeb6c4;
  --c-sup: #1b93e6; --c-ret: #e04a4a; --c-exh: #8b95a7; --c-fresh: #bd7f18;
  --nr-on-wash: var(--accent-2);
  --nr-cta: var(--accent); --nr-cta-hover: var(--accent-2); --nr-cta-ink: #0e0f12;
  --nr-danger-ink: var(--hot);
  color-scheme: dark;
}

/* ══════════════════════════════════════════════════════════════════════════
   THE DRAWING WORKSPACE'S TOKEN LAYER (feature_canvas_bench → css/bench-canon.css).

   app.html — the duct-design workspace, "A · The Bench". Same shape as the
   canvas-v2 block above, remapping a different host: dashboard.css there,
   style.css + ui-next.css here. The workspace never loads plenum.css, and it
   must not — plenum ships `*{}`, `body{}`, `a{}` and `.topbar .stat .menu .wrap`
   as REAL components, and app.html's own shell is `.app` wrapping `.topbar`.
   Linking it would restyle the live editor from under the flag. So plenum's
   names are copied here instead, exactly as the rail and the dashboard do.

   THE REMAP AT THE BOTTOM IS THE LOAD-BEARING HALF. The port relocates the
   editor's existing nodes rather than rebuilding them, so every modal, dropdown,
   properties row and select on app.html comes along wearing style.css's variable
   names. Remapping those names onto the Airflow palette is what stops thirty
   surfaces the port never touches from being a second visual world inside the
   new chrome.

   --d-* is the DRAWING legend and it is NOT chrome: cold blue supply, hot red
   return, graphite exhaust, burnt-orange fresh air. The chrome around it is
   monochrome on purpose, so these four are the only place colour carries
   information. They match physics.js DUCT_COLORS, which is what actually paints
   the canvas — if one moves, both move.
   ══════════════════════════════════════════════════════════════════════════ */
/* BOTH THE ROOT AND THE BODY, and that is not belt-and-braces.
   ui-next.css:30 declares --accent, --accent-hover and --accent-light on
   body.ui-next, which is always on. A custom property set on <body> beats one
   inherited from <html> for body's whole subtree no matter what the specificity
   says — inheritance is not a cascade contest. So declaring the bench palette on
   html alone left every consumer inside the page reading ui-next's #3b82f6, the
   retired supply blue, while the bar and the record (which use --nr-* and
   --accent-wash, names ui-next does not touch) looked correct. That mix is what
   made the Auto-generate button the wrong blue. */
html.bench-v1,
html.bench-v1 body {
  /* plenum's Airflow palette, light — copied verbatim, as above. */
  --paper: #f4f6fa; --panel: #ffffff; --panel-2: #eef2f7; --sunken: #e9edf3;
  --ink: #131722; --ink-2: #4a5568; --ink-3: #6b7585;
  --hair: #e4e9f1; --hair-2: #d5dce8;
  --accent: #1493e6; --accent-2: #0f76bf; --accent-wash: #dcecfb;
  --accent-ring: rgba(20, 147, 230, .30);
  --hot: #e23b3b; --hot-soft: color-mix(in srgb, #e23b3b 12%, transparent);
  --font: 'Sora', system-ui, -apple-system, sans-serif;
  --mono: 'JetBrains Mono', ui-monospace, 'SF Mono', monospace;
  --num: 'Inter', system-ui, sans-serif;
  --r-xs: 7px; --r-xl: 19px;
  --dur: .19s;
  --track-cap: .145em; --track-tight: -.021em; --track-snug: -.012em;
  /* No --spring here. plenum's overshoot curve is for a hover lift and a menu
     pop; nothing in this workspace uses it, and a drawing surface is the last
     place to put bounce. Carrying an unused token would only invite one. */

  /* The status family, re-darkened for a white bar. The --app-* light values
     exist already but are gated on html.sched-light, which never applies to the
     editor — so a supply readout inside capacity rendered #22c55e on white at
     2.3:1. These carry real numbers, not decoration, and have to clear AA.
     --app-bad rather than --app-danger for a fault: --app-danger light is
     #e23b3b, the canon RETURN red to the pixel, and a capacity fault must not
     share a colour with a duct type on a page that shows both. */
  --app-ok: #15803d; --app-warn: #b45309; --app-bad: #dc2626;

  /* the drawing's own legend — the physics of the trade, two hues and a neutral */
  --d-sup: #1493e6; --d-ret: #e23b3b; --d-exh: #6b7585; --d-fresh: #b45309;
  --d-ink: #131722; --d-thin: #9aa4b5; --d-sheet: #ffffff; --d-uncond: #eef2f7;
  --d-room: #4a5568;

  /* The rail's five, on the PAGE rather than scoped to .nr — see the trap named
     in the canvas-v2 block above: scoped to the bar they resolve to nothing out
     here, and the one filled control renders with no ground at all. */
  --nr-on-wash: #0b5f9b;
  --nr-cta: #0f76bf; --nr-cta-hover: #0b5f9b; --nr-cta-ink: #ffffff;
  --nr-on-accent: #0e0f12; --nr-danger-ink: #b32626;

  /* style.css's own names, remapped (css/style.css:1-59). */
  --bg-app: var(--paper); --bg-sidebar: var(--panel); --bg-card: var(--panel);
  --bg-card-hover: var(--panel-2); --bg-canvas: var(--d-sheet);
  --bg-toolbar: var(--panel); --bg-modal: var(--panel); --bg-input: var(--sunken);
  --text-primary: var(--ink); --text-secondary: var(--ink-2); --text-muted: var(--ink-2);
  --accent-hover: var(--accent-2); --accent-fg: #ffffff;
  --accent-light: var(--accent-wash); --accent-glow: none;
  --border: var(--hair); --border-input: var(--hair-2); --border-strong: var(--hair-2);
  --border-focus: var(--accent);
  --radius-sm: var(--r-xs); --radius-md: 8px; --radius-lg: var(--r); --radius-xl: var(--r-lg);
  --accent-soft: var(--accent-wash);
  --accent-edge: color-mix(in srgb, var(--accent) 34%, transparent);
  /* --accent-line ships as a GRADIENT in style.css and is painted as a background.
     Airflow is flat, so it becomes the flat accent rather than `none` — the rules
     that use it expect something paintable. */
  --accent-line: var(--accent);
  --brand-primary: var(--accent); --brand-secondary: var(--accent-2); --brand-accent: var(--accent);
  --surface-hover: var(--panel-2);
  --supply: var(--d-sup); --return-color: var(--d-ret);
  --fresh: var(--d-fresh); --exhaust: var(--d-exh);
  /* Airflow is FLAT: no inset top-light edge, no coloured glow. PRODUCT.md names
     both as the thing that reads "AI-coded". --edge-light is composed INTO the
     three shadows in style.css, so it has to be a valid shadow value, not `none`. */
  --edge-light: 0 0 transparent;
  --shadow-sm: var(--sh-1); --shadow-md: var(--sh-2); --shadow-lg: var(--sh-pop);
  --font-mono: var(--mono);
  --transition: var(--dur) var(--out);
  color-scheme: light;
}

html.bench-v1[data-theme="dark"],
html.bench-v1[data-theme="dark"] body {
  --paper: #0e0f12; --panel: #1b1e25; --panel-2: #23272f; --sunken: #15171c;
  --ink: #f2f4f8; --ink-2: #aeb6c4; --ink-3: #8b95a7;
  --hair: #2c313b; --hair-2: #3a404c;
  --accent: #33b1ff; --accent-2: #5cc0ff; --accent-wash: #0a2233;
  --accent-ring: rgba(51, 177, 255, .32);
  --hot: #ff5c5c; --hot-soft: color-mix(in srgb, #ff5c5c 12%, transparent);
  --d-sup: #33b1ff; --d-ret: #ff5c5c; --d-exh: #8b95a7; --d-fresh: #f5a623;
  --d-ink: #e6eaf2; --d-thin: #5d6674; --d-sheet: #15171c; --d-uncond: #1b1e25;
  --d-room: #aeb6c4;
  --nr-on-wash: var(--accent-2);
  --nr-cta: var(--accent); --nr-cta-hover: var(--accent-2); --nr-cta-ink: #0e0f12;
  --nr-danger-ink: var(--hot);
  --accent-fg: #04121d;
  /* the status family back at its dark-ground brightness */
  --app-ok: #22c55e; --app-warn: #f59e0b; --app-bad: #ef4444;
  color-scheme: dark;
}

