/* ============================================================
   NerdStack retro console theme — spec-ui-foundations.md
   Visual source of truth: NerdStack_Shell_Mockup_v2_Retro.html
   ============================================================ */

/* ---- self-hosted fonts (spec §2: no Google Fonts CDN in prod) ---- */
@font-face{
  font-family:'Press Start 2P';font-style:normal;font-weight:400;font-display:swap;
  src:url('/fonts/press-start-2p-400.woff2') format('woff2');
}
@font-face{
  font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;
  src:url('/fonts/ibm-plex-mono-400.woff2') format('woff2');
}
@font-face{
  font-family:'IBM Plex Mono';font-style:normal;font-weight:500;font-display:swap;
  src:url('/fonts/ibm-plex-mono-500.woff2') format('woff2');
}
@font-face{
  font-family:'IBM Plex Mono';font-style:normal;font-weight:600;font-display:swap;
  src:url('/fonts/ibm-plex-mono-600.woff2') format('woff2');
}
@font-face{
  font-family:'IBM Plex Mono';font-style:normal;font-weight:700;font-display:swap;
  src:url('/fonts/ibm-plex-mono-700.woff2') format('woff2');
}
/* Outfit — the DexHQ brand face (S5). Self-hosted like the two above; no fonts.googleapis.com
   <link> (DECISIONS 2026-07-28), same offline/PWA reasoning as the vendored Chart.js.
   License SIL OFL 1.1: wwwroot/fonts/OFL.txt ships beside the files because the license requires
   it, not as housekeeping. Provenance: fonts.gstatic.com, Outfit v15, fetched 2026-07-28 — the
   gstatic URLs are content-hashed, so record the version if these are ever refreshed.

   ONE @font-face per unicode-range, NOT one per weight. Outfit is a VARIABLE font, so the scoping
   note's "trim 5 weights to 400/600/700" would have saved nothing: Google serves the SAME file for
   every requested weight. Confirmed at table level rather than by URL identity — both subsets carry
   an `fvar` table declaring `wght 100..900` (WOFF2 stores fvar/cmap untransformed, so it parses
   straight out of the brotli stream). `font-weight:100 900` is the range syntax that hands that
   whole axis to the browser; three fixed-weight blocks pointing at one variable file would download
   it once and then throw the axis away. 46 KB for every weight, versus ~90 KB for five static cuts.

   NOT covered by either subset: U+2726 ✦ and U+25C6 ◆, the two DEX glyphs. See --mono/--px in the
   dexhq block for the fallback and why it has to be explicit. */
@font-face{
  font-family:'Outfit';font-style:normal;font-weight:100 900;font-display:swap;
  src:url('/fonts/outfit-latin.woff2') format('woff2');
  unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,
    U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face{
  font-family:'Outfit';font-style:normal;font-weight:100 900;font-display:swap;
  src:url('/fonts/outfit-latin-ext.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;
}

/* ---- design tokens (spec §1) ----
   THE token block. Every colour and every shape constant the shell paints is declared here;
   nothing below this block may carry a colour literal. The one deliberate exception is the
   barcode label preview (.barcode) — see the comment there.

   Adding a theme therefore means overriding VALUES in this block under a [data-theme=…] scope.
   No rule below it should need to change. Tokens that happen to share a value today
   (--ink-dark == --bg, --chart-green == --good, …) are kept SEPARATE on purpose: they answer
   different questions and a light-ground theme pulls them apart. Do not "simplify" by aliasing.

   ---- THEME SCOPING (S3) ----
   The block is scoped to BOTH :root and [data-theme="retro"] as ONE selector list, not two copies —
   a duplicated 60-token block would be exactly the drift this contract exists to prevent.

   * [data-theme="retro"] is the real scope. ThemeService resolves the theme cookie server-side and
     stamps it into <html data-theme="…"> in the first byte (App.razor), so the right tokens are
     live before the first paint. That is why the preference is a cookie and not localStorage.
   * :root is the FALLBACK, and it is load-bearing: it is what renders the shell correctly if the
     attribute is ever missing or holds something we do not recognise. An unknown cookie already
     resolves to the default theme server-side, so this is a second line of defence — but a theme
     system whose failure mode is an unstyled page is not one worth having.

     S9 NOTE, written down because it is exactly the kind of quiet wrongness this programme keeps
     finding: since S9 the cookie-less DEFAULT is dexhq, so this :root fallback is no longer the
     default theme's block. It cannot diverge in practice — App.razor is the only page that links
     this stylesheet and it always stamps a resolved, registered key, so data-theme is never absent
     — which is why :root was deliberately NOT moved onto the dexhq block. Moving it is also not the
     one-line change it looks like: :root and [data-theme="retro"] have EQUAL specificity (0,1,0
     each), so a :root sitting in the LATER dexhq block would win over the retro block and break
     retro outright. What :root promises is "the shell renders", not "the shell renders the default
     theme". ThemeContractTests.Every_token_with_a_csharp_reader_matches_the_css_declaration_for_every_theme
     pins that reading (every token with a C# reader resolves to that theme's own value under
     every registered theme, dexhq included, even though dexhq is not sourced from :root) - it
     does NOT prove dexhq redeclares those tokens in its own block; EffectiveTokens merges :root
     first, so a token dexhq omitted and silently inherited would still pass this one. The
     own-block guarantee is Every_registered_theme_declares_every_token_the_fallback_declares,
     six lines below - so nobody re-derives either reading as a bug.

   Adding a theme = a [data-theme="dexhq"]{…} block AFTER this one. Equal specificity, so source
   order decides and the later block wins. It MUST redeclare every token here: any token it omits
   silently inherits retro's value through :root. ThemeContractTests
   (Every_registered_theme_declares_every_token_the_fallback_declares) enforces that, because
   "silently inherited a dark-theme colour into a light theme" is precisely the class of bug a
   screenshot review reads straight past. */
:root,
[data-theme="retro"]{
  /* ---- brand + status hues ---- */
  --accent:#b45bff;        /* nerd purple — active nav, links, primary buttons   */
  --accent2:#4df3ff;       /* cyan — section headings, secondary actions         */
  /* --info has NO css consumer. It exists so MudBlazor's Info slot is named by a token instead of
     the loose hex that used to sit in NerdTheme.cs — the last un-tokenized colour in the app.
     CORRECTION (S10, measured; S10b, gated): "no css rule uses it" was read for two slices as "no
     surface uses it", which is why the contrast gate never listed it. MUDBLAZOR USES IT — it is a
     GROUND, and Mud paints its own white ContrastText on it. Sync Health fires Severity.Info on
     every queued run, and #fff on this blue was 2.47:1: worse than either of the two button pairs
     already escalated to Miguel.
     RESOLVED (S12, Miguel approved): the VALUE does not move and the INK does. On this theme
     --good/--warn/--bad/--info are also the status INK painted on dark grounds (.s-ok, .hp.warn,
     .t-kill, .banner .bang …), so darkening them to carry white would break their primary job in
     order to fix their secondary one. NerdTheme now hands MudBlazor --ink-dark as the ContrastText
     for all four severity slots — which is exactly what --ink-dark has always meant on this theme,
     "dark ink ON a bright fill". Measured on the UNCHANGED hues: --good 10.70, --warn 12.88,
     --bad 6.16, --info 7.52, all clear of the 5.27 house floor, and no shell pixel moves. */
  --info:#5ba8ff;          /* MudBlazor Info surfaces — 7.52:1 under --ink-dark  */
  /* --dex is THE brand value (DexHQ brand guide, "the one to protect"). It is a FILL/BORDER
     colour: button grounds, rules, the 3px .dexrow spine, the ✦/◆ chip outlines.
     --dex-text is the INK value — NEVER paint text with --dex on this theme's dark grounds.
     Why they had to split: #EC4FA1 is 0.10 darker in OKLCH L than the old #ff7bd5, which costs
     ~2.3 points of contrast. Measured on --panel it lands at 5.02:1 against the §8 floor of
     5.27:1 (= --dim on --panel, read theme-relatively per the 2026-07-28 ledger line), and it
     fails on every other dex ground too — --panel2 4.60, dexrow 4.65, --dex-draft-bg 4.99.
     As a BORDER it is fine: 4.65–5.02:1 all clear the 3:1 WCAG 1.4.11 non-text floor, so the
     brand value keeps every job where legibility isn't the question.
     --dex-text is #EC4FA1 lifted to OKLCH L 0.71 at its EXACT chroma (0.207) and hue (352°) —
     still in sRGB gamut, so it is the same pink, not a different one, and NOT a quiet revert to
     #ff7bd5 (which sits at hue 341°, C 0.189). It clears both readings of §8 on all seven dex
     grounds (worst case 5.37:1 on --panel2 vs a 5.27 floor). A light-ground theme overrides both:
     there --dex-text will be a DARKENED pink, which is exactly why this is a token and not a
     filter. */
  --dex:#EC4FA1;           /* Dex pink — fills, borders, marks                    */
  --dex-text:#fa5dad;      /* Dex pink as INK on a dark ground — see note above   */
  --good:#3ee06c; --warn:#ffd23e; --bad:#ff5b6a;

  /* ---- grounds, darkest → lightest ---- */
  --shadow:#0a0a12;        /* hard offset shadow                                 */
  --inset:#0d0d16;         /* sunken ground: inputs, wells, run log, thumb boxes */
  --inset-2:#101019;       /* one step up — sticky head inside a sunken menu     */
  --bg:#12121c;            /* deep console navy                                  */
  --inset-3:#151521;       /* two steps up — placeholder fill in a sunken menu   */
  --panel:#1b1b28;
  --panel2:#222234;
  --dex-draft-bg:#2a1626;  /* DEX✦ DESCRIBE draft state — pink-tinted panel      */

  /* ---- ink ---- */
  --text:#e8e6f2; --dim:#8f8cab;
  --text-bright:#fff;      /* one step brighter than --text: h1, KPI value, active nav */
  /* --ink-dark is the ink for EVERY saturated fill on this theme as of S12: the cyan/green/pink
     buttons and the avatar already used it, and .btn.primary / .btn.danger / .toggle button.on
     moved onto it because white on --accent (3.57) and on --bad (3.02) FAILED WCAG AA outright.
     It is also what MudBlazor now paints on the four severity fills (see the --info note). */
  --ink-dark:#12121c;      /* dark ink ON a bright fill — every fill, this theme       */
  /* --ink-light is DECLARED AND UNPAINTED on this theme as of S12, and that is the honest state
     rather than an oversight: its only two consumers were the two WCAG failures above. It stays
     because it is the vocabulary counterpart to --ink-dark that a theme with DARK saturated fills
     would need, and because the wordmark gate's positive control measures it. Collapsing the pair
     into one --ink-on-fill token is a follow-up, not a fix this slice was asked for. */
  --ink-light:#fff;        /* light ink on a saturated fill — no shell rule paints it  */

  /* ---- icon cutouts (S4) ----
     The "hole" tone in the two-tone sprites: the ground seen THROUGH the icon, not a colour of
     the icon. It is var(--bg) because that is the surface the sprites were drawn against, and
     because a light theme then gets a correct page-ground hole for free by overriding --bg.
     It is NOT aliased to --bg in the rule, and it is not var(--panel) either, because icons draw
     on several grounds at once (page --bg, sidebar --panel, hovered/active nav --panel2, KPI card
     --panel): one value cannot be right for all of them. Keeping it a separate, INHERITED token is
     what lets a container restate it per surface later without touching the sprite table. */
  --icon-hole:var(--bg);   /* ground showing through a sprite cutout — see .pix .hole */

  /* ---- rules ---- */
  --line:#39395a;          /* chunky 2px borders                                 */
  --hairline:#2a2a40;      /* 1px table/list rule — quieter than --line          */

  /* ---- structural constants (spec §1/§7: the retro shell is square + chunky) ----
     These are what a rounded, hairline theme overrides. Today's values are the retro ones,
     so declaring them changes nothing; they exist so a theme never has to touch a rule. */
  --radius:0;              /* corner radius on panels/buttons                    */
  --border-w:2px;          /* the chunky border weight                           */
  --shadow-offset:4px 4px; /* hard offset of the panel drop shadow               */

  /* ---- alpha tints ----
     Washes over a ground, so they must be re-derived per theme (a .09 purple over navy and
     a .09 purple over paper are not the same wash). Named by ROLE, not by value; the
     near-duplicate alphas below are historical drift, preserved deliberately. */
  --accent-sel:rgba(180,91,255,.09);    /* selected row / card / rate card       */
  --accent-zebra:rgba(180,91,255,.07);  /* grouped band: family row, lane zebra,
                                           and (S5c) the EXPANDED parent row of a
                                           disclosure table — PO, shipment, PO-detail
                                           shipment. Same question: "this row heads
                                           a group of rows below it."               */
  --accent-hover:rgba(180,91,255,.06);  /* hovered tappable row                  */
  --accent-wash:rgba(180,91,255,.12);   /* active module tab gradient wash       */
  --accent-press:rgba(180,91,255,.2);   /* hovered tool button in a gallery cell */
  --accent-sub:rgba(180,91,255,.03);    /* S5c: ground of a nested sub-TABLE revealed
                                           by a disclosure (GearDex forecast months).
                                           Quieter than -zebra because the band it sits
                                           in already carries one.                  */
  --accent2-tint:rgba(77,243,255,.05);  /* info banner fill                      */
  --warn-tint:rgba(255,210,62,.06);     /* warn box + warn banner + (S5c) the buy-list
                                           top-up consolidation row: the generic warn
                                           wash at the generic alpha.              */
  --warn-tint-row:rgba(255,210,62,.05); /* S5c: table row flagged for attention — an
                                           ENDING rate/rule (RoyalDex terms), a licensor
                                           tagged with no deal to pay them. Distinct
                                           alpha from --warn-tint: historical drift,
                                           preserved per the note above.            */
  --bad-tint-row:rgba(255,91,106,.06);  /* failed table row fill                 */
  --bad-tint-banner:rgba(255,91,106,.07); /* bad banner fill                     */
  --bad-tint-conflict:rgba(255,91,106,.05);      /* S5c: a GROUP flagged as an overlap
                                           conflict — a design whose products are also
                                           on another design (double-pay risk).     */
  --bad-tint-conflict-item:rgba(255,91,106,.07); /* S5c: the ITEM inside that group —
                                           a product also carried by a sibling design.
                                           Same alpha as --bad-tint-banner and a
                                           different question; see the drift note.   */
  --bad-press:rgba(255,91,106,.2);      /* hovered destructive tool button       */
  /* S5c: NEUTRAL lifts — no hue, just "one step off the ground it sits on". These are the two
     washes that were written as rgba(255,255,255,…) in Razor, i.e. lift-by-adding-white, which
     is only a lift on a DARK ground. On paper the same ROLE has to be a darken, so the token is
     named for the effect (lift off the ground) and each theme supplies the direction. */
  --lift-band:rgba(255,255,255,.04);    /* subtotal band inside a report table   */
  --lift-cell:rgba(255,255,255,.03);    /* populated cell in a matrix            */
  /* UI-7 — alternating COLUMN band in a wide numeric matrix (.matrixwrap). TRANSPARENT ON RETRO BY
     DECISION, not by omission: Miguel asked for this after a DexHQ screenshot and said nothing about
     retro, and retro is a shipped surface. `transparent` composites to exactly --panel, so retro's
     PNGs are byte-identical BY CONSTRUCTION and turning it on later is a one-value edit here, not
     new code. Miguel: "The banding should be Retro also but we can add it later if needed."

     THE RETRO VALUE IS ALREADY DERIVED, so switching it on is a substitution and not a fresh piece
     of work: replace `transparent` with rgba(180,91,255,.06).

       composite over --panel #1b1b28  ->  #241F35
       --dim  on it  4.92:1   (retro's house floor is --dim on --panel, 5.28; every wash on this
                               theme sits under it BY CONSTRUCTION because a wash lifts the ground
                               off the very pair that defines the floor — the documented wash debt,
                               S12. 4.92 is mid-cohort and clears WCAG AA 4.5.)
       --text on it 12.88:1
       CIELAB dE 8.11 vs the bare card, against retro's own card-against-page separation of 4.68.

     It is the accent family's HOVER rung (.06), one below the row zebra (.07) — the same relative
     position DexHQ's band takes in its own family, so the two themes were derived by one rule and
     not by two tastes. NOT the DexHQ alpha: retro LIFTS off --panel with --bg below it, DexHQ
     DARKENS off white with --bg immediately below, so the alphas are not portable in either
     direction (S10b).

     SWITCHING IT ON IS A TWO-LINE CHANGE, NOT ONE — verified by doing it and watching the build go
     red. The pair (--dim, [--col-band, --panel]) is already in ContrastGateTests and already runs on
     both themes, so it starts measuring 4.92 on its own; but 4.92 is under retro's 5.27 house floor
     and the gate fails it. That is the DOCUMENTED wash debt every retro wash carries (--accent-zebra
     4.86, --lift-cell 4.88, --bad-tint-row 4.92 — every one of them exempted), so it needs a
     companion entry in the retro exemption block at 4.92 with the same reason. Anything else would
     be pretending this wash is different from the fifteen already there. */
  --col-band:transparent;
  /* --dex-line CLOSED (S12, Miguel approved). It carried the PRE-brand pink #ff7bd5 at .45 alpha
     and composited to 2.44 / 2.50 / 2.53 on --panel2 / --panel / --bg — under the 3:1 non-text
     floor on every ground the chip lands on. The approved answer is the one DexHQ already took in
     S5: a SOLID border, not a re-derived alpha. Written as var(--dex) rather than a copy of
     #EC4FA1 so it can never drift from the brand value it is. Measured: 4.60 / 5.02 / 5.49.
     (DexHQ's --dex-line stays its own separately-derived #D63C90 — on paper the brand pink as a
     border is a different problem, and S5 solved it there.)
     --dex-tint is UNCHANGED and needs nothing: it is a .05 BACKGROUND wash, not a mark, so it owes
     no 3:1 of its own, and the inks that land on it measure --dex-text 5.43 and --dim 4.88 — the
     latter being the by-construction retro wash debt (every wash lifts the ground off --panel, the
     darkest card ground, which IS the floor), not a --dex-tint defect. It still carries #ff7bd5
     rather than the brand #EC4FA1; at .05 that difference is sub-LSB, so re-hueing it would move
     pixels for no measurable gain and is left alone. */
  --dex-tint:rgba(255,123,213,.05);     /* DEX ◆ explain sub-row fill            */
  --dex-line:var(--dex);                /* active DEX chip border — SOLID brand pink */
  /* S5: the three dex washes that were hard-coded in RAZOR inline styles, out of reach of
     DesignTokenTests (which scans app.css only). Named by ROLE and kept at their EXACT existing
     alphas rather than collapsed onto --dex-tint — the alphas differ by site, and S1's rule for
     this block is that near-duplicate alphas are preserved deliberately, not tidied. Values are
     byte-for-byte what those three inline styles carried, so retro renders identically. */
  --dex-box:rgba(255,123,213,.06);      /* Dex advisory box fill (PO detail)      */
  --dex-band:rgba(255,123,213,.10);     /* Dex-highlighted total row band         */
  --dex-sel:rgba(255,123,213,.12);      /* selected cell in a Dex matrix          */

  /* ---- scrims (modal/overlay veils) ---- */
  --scrim-heavy:rgba(10,10,18,.92);     /* lightbox + gallery cell toolbar       */
  --scrim-reconnect:rgba(10,10,18,.82); /* Blazor reconnect overlay              */
  --scrim-confirm:rgba(10,10,18,.72);   /* .modalwrap confirm overlay            */
  --scrim-gdmodal:rgba(6,6,12,.66);     /* .gdmodal-back — note: deeper base     */
  --scanline:rgba(255,255,255,.015);    /* CRT overlay stripe                    */

  /* ---- gradient stripe darks ----
     The darker band of each repeating-linear-gradient bar. Paired with the hue it stripes;
     the -alt pair belongs to the cash calendar, which was drawn separately from the HP bar. */
  --stripe-good:#2fbf59;   --stripe-warn:#d8ae23;     --stripe-bad:#d4414f;
  --stripe-accent:#9a3fe0; --stripe-warn-alt:#d1ad33; --stripe-bad-alt:#cc4855;

  /* ---- chart series palette (read by wwwroot/js/nerd-charts.js AND Platform/Ui/ChartPalette.cs) ----
     A CATEGORICAL palette: its job is mutual distinguishability, which is a different job from
     --good/--warn/--bad meaning something. Kept independent even where a value coincides today.
     Any change here must be mirrored in ChartPalette.cs (ChartPaletteTests locks the two together). */
  --chart-blue:#5b8def;    --chart-green:#3ee06c;  --chart-red:#ff5b6a;
  --chart-cyan:#4df3ff;    --chart-grey:#8f8cab;   --chart-purple:#b45bff;
  --chart-yellow:#ffd23e;  --chart-orange:#ff9f43; --chart-pink:#ff7bd5;
  /* S7 — the AMAZON MARKETPLACE RAMP. Not two new identities: two further steps of the SINGLE
     green identity, because Amazon US/CA/MX are one channel in three marketplaces and the palette
     doc's entity-binding rule says green follows the entity. Monthly.razor painted all three the
     same green, so the three bars were indistinguishable.
     Construction (one rule, applied per theme, so a third theme derives its own): keep the base
     green's OKLCH hue and chroma, step L by -0.14 and -0.28, clamp chroma to the sRGB gamut.
     Measured with the S5 palette port: every step clears the 3:1 graphic floor on BOTH of its
     theme's grounds (worst 3.31, retro -3 on --panel), adjacent steps separate by >=13.5 dE under
     the worst of protan/deutan/tritan (target 8.0), and the CATEGORICAL all-pairs worst-CVD is
     UNCHANGED in both themes (retro 2.86 blue<->purple, dexhq 4.19 red<->yellow) — i.e. the ramp
     adds no new worst pair. -0.14 specifically: -0.13 puts retro's mid step at 2.30 against
     --chart-orange, under retro's own floor. */
  --chart-green-2:#00b048; /* Amazon CA — base green, L-0.14   6.47 bg / 5.92 panel */
  --chart-green-3:#007f32; /* Amazon MX — base green, L-0.28   3.62 bg / 3.31 panel */
  --chart-compare:#7a8aa0; /* the "same period last week" reference line         */
  --chart-grid:rgba(57,57,90,.35); /* axis grid — --line, backed off             */

  /* ---- type ---- */
  --px:'Press Start 2P',monospace;               /* headings / flavor           */
  --mono:'IBM Plex Mono',Consolas,monospace;     /* data / body                 */

  /* ---- PER-ELEMENT --px SIZE OVERRIDES (UI-3 / UI-4) --------------------------------------
     Two sizes that are per-THEME rather than per-element, because --px is not one typeface: it is
     Press Start 2P here (a pixel font with a fixed 1em advance, so every size in this stylesheet
     was chosen on its grid) and proportional Outfit on DexHQ, where the same number simply reads
     small. Miguel asked for both by name, at exact values.

     THESE TWO ARE DELIBERATE EXCEPTIONS TO THE --px-scale SLICE THAT IS COMING, not a competing
     mechanism, and they are named to sit beside it. A single scale cannot produce them: 10 -> 14 is
     1.40x and 19 -> 24 is 1.26x. The follow-up scale slice should honour these as overrides.

     A general scale was considered here and rejected for this branch on its own merits too — it
     would drag along every other --px element, including the wordmark and .logo (which SET the
     topbar's 56.9px height, S11) and the mobile tab label S12 deliberately put on the 8px PS2P grid.

     Adding a third: declare it in BOTH blocks. :root also matches [data-theme="dexhq"], so a token
     left out here does not go missing, it silently inherits retro's value —
     ThemeContractTests.Every_registered_theme_declares_every_token_the_fallback_declares is the alarm. */
  --px-modtab:10px;        /* module-switcher labels: .modules a + the mobile menu (UI-1)  */
  --px-kpi:19px;           /* .kpi .value — the KPI headline number                        */
}

/* ============================================================================================
   DexHQ — the light-ground brand theme (S5). THE SECOND THEME.

   Palette derived 2026-07-28 with the dataviz categorical method and accepted (see
   docs/design/dexhq-light-palette.md for every workings, ratio and ΔE). Ground truth from the
   brand guide — #EC4FA1 / #D63C90 / #0E1116 / #FFFFFF / #F4F5F7 / #6B7280 / #E6E8EC — is used as
   given and NOT re-derived here.

   ---- WHY THIS BLOCK RESTATES ALL 63 TOKENS ----
   The shared block above is scoped ":root, [data-theme=retro]". :root ALSO matches
   <html data-theme="dexhq">, so a token omitted here does not go missing — it silently keeps
   RETRO's value. A near-black ground surviving into a light theme reads as a rendering nit in a
   screenshot review and is actually an unreadable control. ThemeContractTests'
   Every_registered_theme_declares_every_token_the_fallback_declares is the alarm; it was written
   in S3 for exactly this moment. Do not "tidy up" by dropping tokens whose value looks unchanged.

   ---- CONTRAST RAIL (spec §8, read theme-relatively per DECISIONS 2026-07-28) ----
   Text floor = this theme's --dim on --panel = #6B7280 on #FFFFFF = 4.83:1, and 4.43:1 against
   the --bg surface, which is the binding ground because it is darker than the card. Graphic
   (non-text) floor = 3:1, WCAG 1.4.11. Both readings are checked in
   docs/design/dexhq-light-palette.md §1; the absolute retro number (5.27:1) is unsatisfiable here
   because the LOCKED muted is itself 4.43:1 on the LOCKED surface.
   ============================================================================================ */
[data-theme="dexhq"]{
  /* ---- brand + status hues ---- */
  --accent:#8D2FDA;        /* nerd purple, deepened — 5.42:1 surface / 5.91:1 card */
  --accent2:#007D88;       /* teal — link + heading ink, 4.49 / 4.90               */
  /* See retro's --info note: Mud consumes this as a GROUND and paints ink on it, so it owes the
     text floor. #fff on the old #2C8BFF was 3.36:1, under this theme's 4.43.
     RESOLVED (S12, Miguel approved) THE OTHER WAY ROUND FROM RETRO, and the asymmetry is the
     point: on paper the severity tokens are FILLS ONLY (the status INK here is --good/--warn/--bad
     read on white, which is the same token doing the same job at 4.85-4.88), and --ink-dark is
     itself #FFFFFF on this theme, so there is no darker ink to swap to. The fix therefore has to
     move the hue — and only --info's, because its three siblings already sit at 4.85-4.88 and this
     one was simply never pulled into their band.
     DERIVED, not eyeballed, by the S7 Amazon-ramp method: take #2C8BFF to OKLCH, step L down at
     CONSTANT chroma and hue until white clears the floor with the siblings' margin. L 0.6439 ->
     0.5543 (-0.09) at C 0.193 / H 256.1deg gives #006EE0 = 4.88:1, landing between --good 4.88 and
     --bad 4.85. Chroma is preserved to 0.0001, so it is the same blue, darker — still unmistakably
     an info blue and not a navy. --chart-blue keeps #2C8BFF: a series stroke owes 3:1, which that
     value clears, and the two tokens are independent by the rule this block already states. */
  --info:#006EE0;          /* MudBlazor Info surfaces — 4.88:1 with white ink     */
  /* THE DEX PINK ON A LIGHT GROUND — the split S2 introduced still holds, but it INVERTS.
     On #12121c the brand #EC4FA1 was fine as a fill and too dark as ink. Here it is the opposite:
     as ink on paper #EC4FA1 measures 3.11:1, well under the 4.43 text floor, and as a BUTTON
     GROUND it cannot carry any ink at text grade (white on it is 3.39:1, near-black is 5.65:1 but
     `.btn.dex` paints --ink-dark, and see the --ink-dark note below for why that is white here).
     So --dex takes the derived text-grade pink #CB2F85 for BOTH roles: 4.49/4.90 as ink, 4.49 as a
     border, and white-on-it is 4.90 so `.btn.dex` and `.freshness .runnow:hover` clear the floor.
     --dex-text is a further step down because `.dex-chip.on` paints it on --panel2, the darkest
     ground any dex ink lands on. The brand #EC4FA1 itself is NOT absent: it is --dex-tint's hue and
     --chart-pink, both of which owe 3:1 or nothing at all.
     The two tokens therefore hold the SAME value here — the split S2 introduced is real on navy and
     collapses on paper, which is the whole reason it is two tokens and not one filter. */
  --dex:#CB2F85;           /* Dex pink — fills, borders, marks                     */
  --dex-text:#CB2F85;      /* Dex pink as INK on paper — 4.90 card / 4.49 surface  */
  --good:#00833A; --warn:#A06400; --bad:#E01536;

  /* ---- grounds ----
     THE ELEVATION LADDER IS COMPRESSED, AND THAT IS A MEASURED RESULT, NOT A ROUGH DRAFT.
     Two brand values are locked: the muted --dim #6B7280 and the surface --bg #F4F5F7. Their
     contrast is 4.43:1 — which IS this theme's binding text floor (see the header). So --dim has
     exactly ZERO headroom: any ground darker than --bg puts secondary text under the floor.
     Measured on the first draft of this block: a #ECEEF2 --panel2 gave 4.16:1 on `.dex-chip`, a
     #E9EBF0 --inset gave 4.05:1 on `.runlog` and 4.11:1 on `.vbadge` — under the house floor and
     under WCAG AA's absolute 4.5:1 as well.
     So every ground here sits between --bg and --panel. The retro ORDER survives (--inset and
     --panel2 deepest, then --inset-2, then --inset-3, then --panel); only the RANGE is squeezed and
     the direction inverted. The consequence worth knowing before S7: on DexHQ a "sunken" or
     "selected" ground is a step TOWARD the page colour rather than away from it, so `.tabs a.on`
     and `.sidenav a:hover` read as state against the WHITE card, not against the page.
     The accepted derivation left --panel2 and --shadow as explicitly unvalidated placeholders;
     this is the pass it asked for, and the compression is the answer it did not have. */
  --shadow:#00000000;      /* NO hard shadow. See the structural note below.       */
  --inset:#F4F5F7;         /* sunken ground: inputs, wells, run log, thumb boxes   */
  --inset-2:#F7F8FA;       /* one step up — sticky head inside a sunken menu       */
  --bg:#F4F5F7;            /* paper — the locked surface                           */
  --inset-3:#FAFBFC;       /* two steps up — placeholder fill in a sunken menu     */
  --panel:#FFFFFF;         /* the locked card                                      */
  --panel2:#F4F5F7;
  --dex-draft-bg:#FDF0F7;  /* DEX✦ DESCRIBE draft state — pink-tinted panel        */

  /* ---- ink ---- */
  --text:#0E1116; --dim:#6B7280;
  --text-bright:#000000;   /* one step STRONGER than --text: h1, KPI value, active nav */
  /* --ink-dark and --ink-light hold the SAME value here, and that is a measured result, not an
     oversight — which is exactly why S1 split them rather than aliasing.
     The four fills that paint --ink-dark (--accent2 on .btn, --good on .btn.good/.bigbtn, --dex on
     .btn.dex/.runnow:hover, --accent on .avatar) all sit at 4.88–5.91:1 against WHITE and at
     3.24–3.93:1 against near-black — i.e. white is the only ink that clears the text floor on all
     four. That is forced, not chosen: for one fill to carry BOTH inks at 4.43:1 it would need
     relative luminance ≥0.193 (for dark ink) and ≤0.187 (for light ink) at the same time.
     Impossible. On #12121c the two tokens genuinely differ; here they converge.
     S12 note: the two extra fills that moved onto --ink-dark on retro (--accent on .btn.primary /
     .toggle button.on, --bad on .btn.danger) were ALREADY white here, so those rules render
     byte-identically on this theme — the fix is retro-only in pixels and both-themes in vocabulary.
     Mud's four severity ContrastText slots now read --ink-dark too, which on this theme is the
     white they already were. */
  --ink-dark:#FFFFFF;      /* ink ON a saturated fill — white on paper-theme fills */
  --ink-light:#FFFFFF;     /* ink ON a saturated fill (primary/danger btn)         */

  /* Same reasoning as retro: the hole is the PAGE ground seen through the sprite, so it follows
     --bg. The retro sprites are what DexHQ still draws (the Material Rounded set is S6), and on
     paper an off-white hole on a white card is a hairline artefact rather than the near-black
     block the baked #12121c produced. */
  --icon-hole:var(--bg);

  /* ---- rules ---- */
  --line:#E6E8EC;          /* the locked hairline                                  */
  --hairline:#F0F2F5;      /* quieter than --line — closer to the card             */

  /* ---- structural constants — THE point of S1 declaring these ----
     Rounded cards, a true hairline, and no hard offset shadow. --shadow-offset zeroes the .panel
     shadow, but `.btn`/`.bigbtn`/`.thumbbig` hard-code their own 3px/6px offsets, so the offset
     token alone would leave buttons wearing a retro shadow. --shadow:#00000000 (fully transparent)
     is what actually removes every hard shadow in the theme without touching a rule. */
  --radius:16px;
  --border-w:1px;
  --shadow-offset:0 0;

  /* ---- alpha tints ----
     THE HUES ARE RE-DERIVED AND SO ARE THE ALPHAS. S5 wrote "alphas preserved: a wash is a ROLE,
     and the retro alphas read the same on paper (a dark tint dropping out of white) as they do on
     navy (a light tint lifting off it)". S10 MEASURED that claim on live GearDex Transfers and
     Admin Sync Health rows and it is FALSE — five washes composited to a ground DARKER than --bg,
     putting --dim under this theme's own 4.43:1 floor (--dex-sel 4.20, --dex-band 4.30,
     --bad-tint-conflict-item / --bad-tint-banner 4.31, --accent-zebra 4.35). S10b re-measured every
     wash in this block and found three more the QA pass had not rendered (--accent-sel 4.21,
     --bad-tint-row 4.39, --accent-hover 4.41).

     WHY THE ALPHAS COULD NOT SURVIVE THE PORT — the asymmetry is structural, not a tuning slip.
     Retro washes LIFT off --panel #1b1b28 and the binding ground --bg #12121c is BELOW them, so a
     wash moves AWAY from the constraint and has effectively unlimited room. DexHQ washes DARKEN
     off --panel #FFFFFF and the binding ground --bg #F4F5F7 is immediately BELOW them, so a wash
     moves STRAIGHT AT the constraint. The whole usable band is #FFFFFF → #F4F5F7. Retro's alphas
     spend more than that band contains.

     THE DERIVATION RULE (values-only, mechanical, re-runnable for a third theme):
       1. The grounds rule this block states about itself — "no ground may be darker than --bg" —
          is measured as: --dim on (wash over --panel) >= --dim on --bg = 4.4318:1. That is not a
          proxy, it is EQUIVALENT: contrast against a ground is monotone in the ground's luminance
          for any ink darker than both, so a wash no darker than --bg keeps EVERY ink that already
          clears the floor on --bg. One check certifies the lot.
       2. That gives each HUE a maximum alpha on white: accent .0575, bad .0537, dex .0741,
          warn .0675, accent2 .0648, neutral --text .0422.
       3. Inside a hue family the RUNG ORDER is what carries meaning, so the family is scaled from
          the top rung down until it fits under the ceiling. A rung is left alone once it already
          clears the floor AND still sits below the rung above it — which is why --accent-sub,
          --accent2-tint, --warn-tint, --warn-tint-row, --lift-band and --lift-cell are untouched,
          and why --bad-tint-conflict / --dex-tint / --dex-box moved even though they passed: the
          rungs above them had to come down THROUGH them.
       4. Visibility floor: a wash must separate from its unwashed neighbour by at least the
          separation this theme already relies on being visible — the card against the page,
          CIELAB dE 3.65 (#FFFFFF vs #F4F5F7). Every re-derived wash lands 3.78–5.89.
     Measured results are on each line: composite over --panel, then --dim on it, then dE vs the
     bare card. ContrastGateTests now measures all seventeen, in both themes, so this cannot rot.

     NOT re-derived, and deliberately: --accent-wash, --accent-press and --bad-press are CONTROL
     states, not row/card/banner grounds — they carry one named ink each (--text-bright, --accent,
     --bad) and never carry --dim. --accent-press / --bad-press also sit inside .gcelltools, which
     is a --scrim-heavy bar over arbitrary product PHOTOGRAPHY: there is no token underneath, so no
     gate can measure it and no alpha here can fix it.
     --accent here is rgb(141,47,218), --accent2 rgb(0,125,136), --warn rgb(160,100,0),
     --bad rgb(224,21,54), --dex-tint's brand pink rgb(236,79,161), the neutral lift rgb(14,17,22). */
  --accent-sel:rgba(141,47,218,.05);    /* selected row / card / rate card  .09→.05  #F9F5FD 4.48 dE 5.58 */
  --accent-zebra:rgba(141,47,218,.04);  /* grouped band + expanded parent   .07→.04  #FAF7FE 4.55 dE 4.46 */
  --accent-hover:rgba(141,47,218,.035); /* hovered tappable row             .06→.035 #FBF8FE 4.59 dE 3.90 */
  --accent-wash:rgba(141,47,218,.12);   /* active module tab gradient wash — CONTROL, ink --text-bright */
  --accent-press:rgba(141,47,218,.2);   /* hovered tool button in a gallery cell — CONTROL, over photos  */
  --accent-sub:rgba(141,47,218,.03);    /* nested sub-table ground          unchanged #FCF9FE 4.62 */
  --accent2-tint:rgba(0,125,136,.05);   /* info banner fill                 unchanged #F2F8F9 4.52 */
  --warn-tint:rgba(160,100,0,.06);      /* warn box + banner + top-up row   unchanged #F9F6F0 4.48 */
  --warn-tint-row:rgba(160,100,0,.05);  /* table row flagged for attention  unchanged #FAF7F2 4.53 */
  --bad-tint-row:rgba(224,21,54,.045);  /* failed table row fill            .06→.045 #FEF4F6 4.50 dE 4.43 */
  --bad-tint-banner:rgba(224,21,54,.05); /* bad banner fill                 .07→.05  #FDF3F5 4.46 dE 4.92 */
  --bad-tint-conflict:rgba(224,21,54,.04);      /* conflicting group row    .05→.04  #FEF6F7 4.53 dE 3.93 */
  --bad-tint-conflict-item:rgba(224,21,54,.05); /* conflicting item row     .07→.05  #FDF3F5 4.46 dE 4.92 */
  --bad-press:rgba(224,21,54,.2);       /* hovered destructive tool button — CONTROL, over photos       */
  /* The neutral lifts INVERT here, and that is the whole reason they are tokens. Retro lifts a row
     off a dark ground by adding white; on paper white-on-white is nothing at all, so DexHQ darkens
     instead — derived from --text rgb(14,17,22) at the same alphas, exactly as the scrims are.
     Both composite LIGHTER than --bg (#F5F5F6 and #F8F8F8 over --panel — S5 wrote #F7F7F8/#F8F8F9,
     which was a slip; corrected here after S10b re-measured every wash), so they respect this
     theme's "no ground darker than --bg" rule; --dim on either reads at or above its 4.43:1 floor,
     because the ground moved away from --dim, not toward it. --lift-band at .04 is the neutral
     hue's ceiling almost exactly (.0422), which is why it is the only wash in this block that is
     already at the edge and could not have taken one more step. */
  --lift-band:rgba(14,17,22,.04);       /* subtotal band in a report  unchanged #F5F5F6 4.45 */
  --lift-cell:rgba(14,17,22,.03);       /* populated matrix cell      unchanged #F8F8F8 4.55 */
  /* UI-7 — the column band, derived by the S10b method above rather than eyeballed. It is the ACCENT
     family's .035 rung (the one --accent-hover already occupies), chosen as the LOWEST rung that
     still clears the theme's visibility floor: composite over --panel #FBF8FE, --dim on it 4.59:1
     against the 4.43 floor, CIELAB dE 3.90 against the 3.65 floor. The next rung down (--accent-sub
     .03) measures dE ~3.34 and would be a band nobody can see; the rung above (--accent-zebra .04,
     dE 4.46) is the ROW zebra, and a column band should not outweigh the row band that groups
     records. A NEUTRAL grey band cannot do this job at all: the darkest neutral that clears 4.43 is
     #F5F5F5 at dE 3.46, i.e. under the visibility floor — the hue is what buys the separation. */
  --col-band:rgba(141,47,218,.035);     /* wide-matrix column band    #FBF8FE 4.59 dE 3.90 */
  /* The retro pair is an OPEN QUESTION for Miguel (--dex-line composited over --panel2 measures
     2.43:1 there, already under the 3:1 non-text floor on main today) and is deliberately NOT
     touched by this slice — it changes a shipped surface and the fix is a design call. DexHQ has
     no shipped surface, so its equivalents are derived properly and the two are DECOUPLED:
       --dex-tint keeps the alpha wash form at the real brand pink, and is a BACKGROUND, so it owes
       no ratio of its own (the --dim/--dex-text it carries are measured against the composite).
       --dex-line is SOLID, not an alpha — that is the recommendation from the same open question,
       taken here because there is nothing to regress. It is the locked brand pink-dark #D63C90,
       which measures 3.92:1 on --bg, 4.28:1 on --panel and 3.72:1 on --panel2, i.e. it clears the
       3:1 non-text floor on every ground the chip can land on, with margin. */
  /* The four dex WASHES are one family and take the same S10b derivation as the tints above: the
     brand pink's ceiling on white is alpha .0741, and retro spent .12 on the top rung alone. The
     ladder sel > band > tint > box is preserved by scaling from the top, which is why --dex-tint
     and --dex-box moved despite already clearing the floor — the two rungs above them had to come
     down through them. --dex-line is SOLID and is not part of this: it owes 3:1 as a border, not a
     ground, and it is untouched. */
  --dex-tint:rgba(236,79,161,.05);      /* DEX ◆ explain sub-row fill   .07→.05  #FEF6FA 4.56 dE 4.20 */
  --dex-line:#D63C90;                   /* active DEX chip border — solid, 3.72:1 worst */
  --dex-box:rgba(236,79,161,.045);      /* Dex advisory box (PO detail) .06→.045 #FEF7FB 4.59 dE 3.78 */
  --dex-band:rgba(236,79,161,.06);      /* Dex-highlighted total band   .10→.06  #FEF4F9 4.51 dE 5.04 */
  --dex-sel:rgba(236,79,161,.07);       /* selected cell in a Dex matrix .12→.07 #FEF3F8 4.45 dE 5.89 */

  /* ---- scrims (modal/overlay veils) ----
     A veil darkens whatever it covers, on paper as much as on navy, so these stay dark — derived
     from --text rgb(14,17,22) with the retro alphas, and --scrim-gdmodal keeps its deeper base. */
  --scrim-heavy:rgba(14,17,22,.92);     /* lightbox + gallery cell toolbar       */
  --scrim-reconnect:rgba(14,17,22,.82); /* Blazor reconnect overlay              */
  --scrim-confirm:rgba(14,17,22,.72);   /* .modalwrap confirm overlay            */
  --scrim-gdmodal:rgba(6,7,10,.66);     /* .gdmodal-back — note: deeper base     */
  /* The CRT scanline is a RETRO conceit, not a shell feature. A white stripe over paper is
     invisible and a dark one would be a texture nobody asked for, so DexHQ turns the overlay off
     the only way a values-only theme can: a fully transparent stripe. body::after still paints;
     it paints nothing. */
  --scanline:rgba(255,255,255,0);

  /* ---- gradient stripe darks ----
     One step deeper than the hue each stripes, same relationship as retro. */
  --stripe-good:#006B2F;   --stripe-warn:#855300;     --stripe-bad:#B8102C;
  --stripe-accent:#7526B5; --stripe-warn-alt:#8A5700; --stripe-bad-alt:#C01230;

  /* ---- chart series palette ----
     The light-ground categorical set from the derivation: all nine clear 3:1 on BOTH grounds, and
     as an ordered adjacent set it passes all six dataviz checks (worst adjacent CVD ΔE 14.90 vs a
     target of 8.0; worst normal-vision ΔE 20.85 vs a floor of 15.0) — genuinely stronger than the
     retro chart colours.
     *** S5's DECLARED GAP IS CLOSED BY S7. These tokens were read by nerd-charts.js for chart
     CHROME while Chart.js DATASET colours came from a static C# ChartPalette with one set of
     values — so a DexHQ page drew light-ground chrome around RETRO-coloured series, and S5b's
     capture measured retro cyan #4DF3FF on a #FFFFFF panel at ~1.3:1. ChartPalette is now built
     PER THEME from NerdThemes' token map, and ThemeContractTests locks that map against this block
     in both directions — so these values are now the ones a DexHQ chart actually draws. *** */
  --chart-blue:#2C8BFF;    --chart-green:#00873C;  --chart-red:#E62A3F;
  --chart-cyan:#00919D;    --chart-grey:#6B7280;   --chart-purple:#8D2FDA;
  --chart-yellow:#A88800;  --chart-orange:#B65400; --chart-pink:#EC4FA1;
  /* The Amazon marketplace ramp, derived by the SAME rule as retro's (base green OKLCH L-0.14 /
     L-0.28, hue and chroma held) — which is the point of it being a rule and not two hand-picked
     hexes. 7.84/8.55 and 13.62/14.86 against --bg/--panel. */
  --chart-green-2:#005925; /* Amazon CA */
  --chart-green-3:#002f0f; /* Amazon MX */
  --chart-compare:#7E8899; /* the "same period last week" reference line         */
  --chart-grid:rgba(107,114,128,.22); /* axis grid — --dim, backed off           */

  /* ---- type ----
     Outfit for both tiers: DexHQ is one brand face at two weights/sizes, and Press Start 2P is a
     retro conceit that cannot follow it. See the @font-face note at the top of this file for why
     one variable file covers every weight.

     THE SYMBOL FALLBACK IS LOAD-BEARING, NOT DEFENSIVE. ✦ (U+2726) and ◆ (U+25C6) are the DEX
     glyph grammar (2026-07-22: ✦ = Dex makes, ◆ = Dex knows) and they appear in the DEX✦HQ lockup,
     every .dex-sfx module suffix, .dex-insight-body h3::before, and the BuyList ◆ math trail.
     Neither is in either Outfit subset. Verified by parsing the cmap tables directly: they are in
     NEITHER Outfit subset, NEITHER self-hosted IBM Plex Mono cut, NOT Press Start 2P, and NOT
     Consolas — i.e. NOTHING WE SHIP OR NAME RESOLVES THEM TODAY. On this box they render only
     because Chrome's last-resort fallback reaches Segoe UI Symbol, which is an accident of the
     platform and not something the retro stack asks for. Naming the symbol faces makes that
     explicit and portable; the generic sans-serif is the last stop. (RESIDUAL: a Linux box with
     none of the three named symbol faces installed still shows tofu — unchanged from today, and
     the real fix is drawing the two brand marks as SVG, which is a lockup change, not a theme.) */
  --px:'Outfit','Segoe UI Symbol','Apple Symbols','Noto Sans Symbols 2',sans-serif;
  --mono:'Outfit','Segoe UI Symbol','Apple Symbols','Noto Sans Symbols 2',sans-serif;

  /* UI-3 / UI-4 — the two sizes Miguel named on this theme. Outfit is proportional, so retro's
     on-grid 10px/19px read small here. Exact values, not a ratio: see the fallback block. */
  --px-modtab:14px;        /* was inheriting retro's 10px                                  */
  --px-kpi:24px;           /* was inheriting retro's 19px                                  */
}

*{box-sizing:border-box;margin:0}
html,body{height:100%}
body{
  font-family:var(--mono);background:var(--bg);color:var(--text);
  min-height:100vh;font-size:16px;-webkit-font-smoothing:auto;
}
a{color:var(--accent2);text-decoration:none}

/* faint CRT scanline overlay — spec §1/§8: subtle, removable via .no-crt on <body> */
body::after{
  content:"";position:fixed;inset:0;pointer-events:none;z-index:99;
  background:repeating-linear-gradient(0deg,var(--scanline) 0 1px,transparent 1px 3px);
}
body.no-crt::after{display:none}

/* pixel-corner panel helper — THE card archetype. Border weight, corner radius and shadow offset
   come from the structural tokens, so a rounded/hairline theme is a value change in :root and
   nowhere else. border-radius is stated explicitly even though it computes to 0 today: without
   the declaration a theme has no property to override. */
.panel{background:var(--panel);border:var(--border-w) solid var(--line);border-radius:var(--radius);
  box-shadow:var(--shadow-offset) 0 var(--shadow)}

/* ================= TOP BAR: brand + MODULE SELECT ================= */
.topbar{background:var(--panel);border-bottom:var(--border-w) solid var(--line);display:flex;align-items:center;gap:0;position:sticky;top:0;z-index:10;
  padding:env(safe-area-inset-top) calc(16px + env(safe-area-inset-right)) 0 0}
/* logo box spans the 200px left-panel width (matches .frame's sidenav track) and centres the mark;
   with the topbar's left padding + gap removed, .modules then begins exactly at the sidenav edge. */
.logo{width:200px;flex:none;padding:15px 0;white-space:nowrap;display:flex;align-items:center;justify-content:center}
.logo img{height:26px;width:auto;display:block}
.logo span{color:var(--accent)}
/* ---- the wordmark LOCKUP (S11) — used by the themes whose IWordmark is "lockup" -------------
   The DexHQ brand guide does not ship the wordmark as an image; it BUILDS it: DEX in Dex Pink, the
   four-point star as the divider, HQ in ink, with gap .16em / weight 800 / letter-spacing -.02em /
   star .65em. Those five numbers are the guide's own, ported not invented. Colours come from tokens
   and never from literals here — that is what makes this mark measurable by ContrastGateTests,
   which the raster it replaces never was (S10 FINDING 1: its "HQ" was pure #FFFFFF on DexHQ's
   #FFFFFF --panel, i.e. 1.00:1, on all 24 pages and on the sign-in screen).

   --wm-box is the BOX height and is set to exactly the height the raster's <img> had at each site,
   so the lockup occupies the same layout slot: .logo is the tallest thing in .topbar and therefore
   sets the topbar's height, and a taller mark would move every pixel on every page. The glyph ink
   overshoots that box by ~1.5px top and bottom (font-size is 1.14x the box so the CAP height lands
   where the raster's did) — deliberate, unclipped, and well inside .topbar's 15px padding.

   DEX and the star share ONE token on purpose: the guide's first Don't is "recolor the star", and
   two tokens that merely happen to be equal today would let a future theme drift them apart. */
.wordmark{display:inline-flex;align-items:center;gap:.16em;height:var(--wm-box,26px);
  font-family:var(--px);font-weight:800;font-size:calc(var(--wm-box,26px) * 1.14);letter-spacing:-.02em;line-height:1}
.wordmark .wm-dex{color:var(--dex-text)}
.wordmark .wm-star{width:.65em;height:.65em;flex:none;color:var(--dex-text)}
.wordmark .wm-hq{color:var(--text-bright)}
.modules{display:flex;gap:2px;flex:1;overflow-x:auto}
.modules a{font-family:var(--px);font-size:var(--px-modtab);color:var(--dim);text-decoration:none;padding:18px 15px 14px;border-bottom:3px solid transparent;white-space:nowrap}
.modules a:hover{color:var(--text)}
.modules a.active{color:var(--text-bright);border-bottom-color:var(--accent);background:linear-gradient(180deg,transparent 60%,var(--accent-wash))}
.modules a.locked{opacity:.35;cursor:not-allowed}
/* DEX brand suffix — the "DEX" in every module label renders in Dex pink (--dex) while the prefix
   keeps its own colour. Shared by the top selector + the sidebar module-name header (.modname). */
.dex-sfx{color:var(--dex-text)}
.userchip{display:flex;align-items:center;gap:8px;font-size:14px;color:var(--dim);white-space:nowrap}
.userchip .hp{color:var(--good)}
.userchip .hp.warn{color:var(--warn)}
.userchip .hp.bad{color:var(--bad)}
.userchip a{color:var(--dim)}
.avatar{width:30px;height:30px;background:var(--accent);color:var(--ink-dark);display:grid;place-items:center;font-family:var(--px);font-size:12px}

/* ---- ACCOUNT MENU / THEME SELECTOR (S8) ----
   The avatar is a disclosure trigger (theme choices + Sign out); see UserMenu.razor for why this is
   a disclosure and not an ARIA menu.

   THE CLOSED STATE MUST NOT MOVE A PIXEL. This topbar is photographed on all 24 harness pages in
   both themes, so .usermenu-trigger exists to strip every default a <button> brings that the <a> it
   replaced did not — border, padding, margin, background, font, text-align, platform appearance.
   The .avatar inside is untouched and .usermenu is a plain block flex item exactly where the anchor
   was, so the flex line is unchanged.

   Every ink/ground pair below is one the contrast gate already measures: --dim and --text-bright on
   --panel, --text on --panel2 (the .sidenav a:hover pairing), --accent and --line as marks. This
   control introduces no colour and no pairing that is not already gated. */
.usermenu{position:relative;display:flex;align-items:center}
.usermenu-trigger{display:block;margin:0;padding:0;border:0;background:none;font:inherit;
  text-align:left;color:var(--dim);cursor:pointer;-webkit-appearance:none;appearance:none}
.usermenu-trigger:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
/* Dismiss layer: transparent, viewport-sized, painted under the panel and over the trigger by DOM
   order alone (both positioned, both z-index:auto). Fixed rather than absolute so a click anywhere
   in the app closes the menu, including over the sticky topbar itself. */
.usermenu-scrim{position:fixed;inset:0;background:transparent}
/* right:0 anchors the panel to the avatar's right edge, which the topbar's own padding already
   holds 16px off the viewport. max-width keeps it inside a 380px screen with no media query. */
.usermenu-panel{position:absolute;top:calc(100% + 10px);right:0;min-width:212px;
  max-width:calc(100vw - 24px);background:var(--panel);border:var(--border-w) solid var(--line);
  border-radius:var(--radius);box-shadow:var(--shadow-offset) 0 var(--shadow);padding:6px}
.usermenu-head{font-family:var(--px);font-size:9px;color:var(--dim);letter-spacing:1px;
  padding:8px 10px 6px;margin:0}
.usermenu-item{display:flex;align-items:center;gap:10px;width:100%;box-sizing:border-box;margin:0;
  padding:9px 11px;border:0;background:none;font-family:var(--mono);font-size:14px;text-align:left;
  text-decoration:none;color:var(--dim);cursor:pointer;-webkit-appearance:none;appearance:none}
.usermenu-item:hover{color:var(--text);background:var(--panel2)}
.usermenu-item:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
.usermenu-item[aria-pressed="true"]{color:var(--text-bright);background:var(--panel2)}
/* The selected marker is DRAWN, not typed: S5 found the shell's ✦/◆ live in no font this app ships,
   so a glyph here would depend on an OS fallback. A box needs no font and stays square in both
   themes. */
.usermenu-mark{width:8px;height:8px;flex:none;background:transparent}
.usermenu-item[aria-pressed="true"] .usermenu-mark{background:var(--accent)}
/* --line, not --hairline: --line on --panel is a pairing the contrast gate already measures (and
   already carries a logged exemption), and it is the token .sidenav .sep separates with. Reaching
   for --hairline would have introduced an unmeasured pair on a token the gate does not name. */
.usermenu-sep{border-top:var(--border-w) solid var(--line);margin:6px 4px}

/* ---- MOBILE MODULE MENU (UI-1) ----
   The <=840px replacement for the .modules strip: a hamburger trigger in the strip's own slot and a
   pop-down under the topbar. HIDDEN AT DESKTOP, HERE, unconditionally — the ≤840px block below only
   ever turns it ON. That direction matters: a component that is display:none by default cannot move
   a desktop pixel however the media query is later edited, which is what makes "the 24 desktop PNGs
   are byte-identical" a structural claim rather than a promise.

   THE PANEL'S CONTAINING BLOCK IS .topbar, NOT .modmenu. .topbar is position:sticky, i.e. already a
   positioned element, so an absolutely positioned descendant anchors to it — which is how the panel
   spans the full phone width instead of only the trigger's slot, with no new position:relative and
   no z-index of its own (it inherits the topbar's stacking context, z-index:10, so it paints over
   the page and under .tabbar's 20 — and the two can never both be open anyway).

   COLOUR: every ink/ground pair below is one ContrastGateTests already measures — --dim and
   --text-bright on --panel, --text and --text-bright on --panel2 (the .sidenav a:hover / a.active
   pairings), --dex-text on both, --accent as a mark on --panel2, --line as a hairline. This control
   introduces no colour and no pairing that was not already gated; the pair table's Rule strings were
   extended to name these selectors rather than leaving the pairings implicit. */
.modmenu{display:none;align-items:center;flex:1;min-width:0}
.modmenu-trigger{display:flex;align-items:center;gap:10px;margin:0;padding:8px 12px;border:0;
  background:none;font:inherit;text-align:left;color:var(--dim);cursor:pointer;min-width:0;
  -webkit-appearance:none;appearance:none}
.modmenu-trigger:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
/* The hamburger is DRAWN, not typed — S5 established that the shell's own ✦/◆ live in no font this
   app ships, and ☰ (U+2630) is in none of them either. Three boxes need no font and stay square in
   both themes. currentColor so the mark states its ink exactly once, at the trigger. */
.barsmark{display:flex;flex-direction:column;justify-content:space-between;width:18px;height:14px;flex:none}
.barsmark i{display:block;width:100%;height:2px;background:currentColor}
/* The active module's name, so the phone topbar still says WHERE YOU ARE — the scrolling strip did,
   and .sidenav (which carries .modname) is display:none at this width. overflow:hidden and no
   ellipsis: an ellipsis is a glyph, and this stylesheet does not depend on glyphs it cannot name. */
.modmenu-here{font-family:var(--px);font-size:var(--px-modtab);color:var(--text-bright);
  white-space:nowrap;overflow:hidden;min-width:0}
.modmenu-scrim{position:fixed;inset:0;background:transparent}
.modmenu-panel{position:absolute;top:100%;left:0;right:0;margin:8px 12px 0;
  background:var(--panel);border:var(--border-w) solid var(--line);border-radius:var(--radius);
  box-shadow:var(--shadow-offset) 0 var(--shadow);padding:6px;
  /* Backstop only. Six modules at ~40px is ~300px, so this bites on a landscape phone and nowhere
     else — a clipped menu would be worse than a scrolled one. It is NOT the expected state: the
     whole point of this slice is that the module list does not scroll. */
  max-height:calc(100vh - 120px);overflow-y:auto}
.modmenu-head{font-family:var(--px);font-size:9px;color:var(--dim);letter-spacing:1px;
  padding:8px 10px 6px;margin:0}
.modmenu-item{display:block;width:100%;box-sizing:border-box;margin:0;padding:11px 12px;
  border:0;border-left:3px solid transparent;background:none;font-family:var(--px);
  font-size:var(--px-modtab);line-height:1.4;text-align:left;text-decoration:none;color:var(--dim);
  cursor:pointer;overflow-wrap:anywhere}
.modmenu-item:hover{color:var(--text);background:var(--panel2)}
.modmenu-item:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
/* Same active treatment as .sidenav a.active — accent rail, lifted ground, bright ink. Reused
   rather than reinvented so the vertical nav reads the same wherever it appears, and so this row
   introduces no ink/ground pair the gate has not already measured. */
.modmenu-item.active{color:var(--text-bright);background:var(--panel2);border-left-color:var(--accent)}
/* Identical to .modules a.locked, on the identical --panel ground: the composite a locked row
   produces is the one the strip already shipped, not a new one. */
.modmenu-item.locked{opacity:.35;cursor:not-allowed}

/* ================= LAYOUT: module sidebar + content ================= */
.frame{display:grid;grid-template-columns:200px 1fr;min-height:calc(100vh - 64px)}
.sidenav{background:var(--panel);border-right:var(--border-w) solid var(--line);padding:16px 10px}
.sidenav .modname{font-family:var(--px);font-size:11px;color:var(--text-bright);padding:4px 8px 14px;letter-spacing:1px}
.sidenav a{display:flex;align-items:center;gap:10px;color:var(--dim);text-decoration:none;padding:9px 11px;font-size:14px;border-left:3px solid transparent;margin:2px 0}
.sidenav a:hover{color:var(--text);background:var(--panel2)}
.sidenav a.active{color:var(--text-bright);background:var(--panel2);border-left-color:var(--accent)}
.sidenav a.active::after{content:"\25C0";color:var(--accent);font-size:10px;margin-left:auto;font-family:var(--px)}
.sidenav .sep{border-top:var(--border-w) dashed var(--line);margin:12px 6px}
.sidenav .navgroup{font-size:10.5px;color:var(--dim);text-transform:uppercase;letter-spacing:2px;padding:14px 8px 6px;border-top:var(--border-w) dashed var(--line);margin-top:10px}

/* icon host (spec §5). SIZE only — the render CONTEXT owns how big an icon is (16px inline here,
   40px .pix.lg page sprite, 44px .kpicon, 20px in the mobile tab bar), and every icon set has to
   draw legibly at all four.
   NOT here any more: shape-rendering. This rule used to carry `shape-rendering:crispEdges`, which
   SILENTLY OVERRODE the same attribute on the <svg> (a rule always beats a presentation attribute).
   Rasterisation belongs to the ARTWORK, not the shell — crispEdges is right for a unit grid and
   visibly jagged on a curve — so it now comes from IIconSet.ShapeRendering and is stated exactly
   once. Do not reintroduce it here: that re-imposes pixel rules on every future set. */
.pix{width:16px;height:16px;flex:none}
/* Icon CUTOUTS. Twelve pixel sprites are two-tone, and the second tone is a HOLE rather than a
   colour — a crate seam, a clipboard rule, a crown jewel. They used to hardcode the retro page
   background inside PixelIcons.cs, i.e. a colour literal no theme could reach, and one that is
   ALREADY a shade off wherever an icon draws on --panel/--panel2 rather than on the page ground.
   It is a token now. --icon-hole INHERITS, so a container that draws on a different surface can
   restate it (e.g. `.iconkpi{--icon-hole:var(--panel)}`) with no C# change; no such override ships
   today, deliberately — the ground-matching question is queued, see the S4 PR body. */
.pix .hole{fill:var(--icon-hole)}

.content{padding:22px;max-width:1440px;min-width:0}
/* table-heavy screens (sync console, buy list, order queue): no max-width */
.content-wide{padding:22px;min-width:0}
.pagehead{display:flex;align-items:center;gap:14px;margin-bottom:18px;flex-wrap:wrap}
.pagehead h1{font-family:var(--px);font-size:17px;color:var(--text-bright)}
.pagehead .sub{color:var(--dim);font-size:14.5px}
.pagehead .spacer{margin-left:auto}
/* 40px page-identity sprite (spec: page sprites) — sits before the h1 in a pagehead */
.pix.lg{width:40px;height:40px}
.syncchip{margin-left:auto;font-size:13.5px;color:var(--good);border:var(--border-w) solid var(--line);padding:5px 11px;background:var(--panel)}
.syncchip.warn{color:var(--warn)} .syncchip.bad{color:var(--bad)}

/* ===== KPI cards ===== */
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:12px;margin-bottom:18px}
.kpi{padding:13px 15px}
.kpi .label{font-size:14px;font-weight:700;color:var(--dim);text-transform:uppercase;letter-spacing:1px}
.kpi .value{font-family:var(--px);font-size:var(--px-kpi);margin-top:9px;color:var(--text-bright)}
.kpi .delta{font-size:13.5px;margin-top:7px;color:var(--dim)}
.up{color:var(--good)} .down{color:var(--bad)} .flat{color:var(--dim)}

/* ===== generic card ===== */
.card{padding:16px 18px;margin-bottom:14px}
.card h3{font-family:var(--px);font-size:11.5px;color:var(--accent2);margin-bottom:16px;letter-spacing:.5px}
.grid2{display:grid;grid-template-columns:1.35fr 1fr;gap:14px}
/* PO detail: give the left column (wide LINE ITEMS table) more room; right column holds the
   narrower detail + payment cards. Defined before the ≤ mobile media query so .grid2{1fr} still
   collapses it to a single column on small screens. */
.grid2.po-grid{grid-template-columns:1.85fr 1fr}

/* ===== HP-bar (budget/quota pacing) ===== */
.hp{margin-bottom:14px}
.hp .row{display:flex;justify-content:space-between;font-size:14.5px;margin-bottom:5px;gap:10px;flex-wrap:wrap}
.hp .row b{color:var(--text-bright)}
.bar{height:16px;background:var(--inset);border:var(--border-w) solid var(--line);position:relative}
.bar .fill{height:100%;image-rendering:pixelated;
  background:repeating-linear-gradient(90deg,var(--good) 0 8px,var(--stripe-good) 8px 10px)}
.bar .fill.warn{background:repeating-linear-gradient(90deg,var(--warn) 0 8px,var(--stripe-warn) 8px 10px)}
.bar .fill.crit{background:repeating-linear-gradient(90deg,var(--bad) 0 8px,var(--stripe-bad) 8px 10px)}
.hp .hint{font-size:13px;color:var(--dim);margin-top:4px}

/* ===== quest log ===== */
.quest{display:flex;gap:10px;padding:11px 6px;border-bottom:var(--border-w) dashed var(--line);align-items:baseline}
.quest:last-child{border-bottom:none}
.quest .mark{font-family:var(--px);font-size:14px;width:24px;flex:none}
.q-bad .mark{color:var(--bad)} .q-warn .mark{color:var(--warn)} .q-ok .mark{color:var(--good)}
/* S12 — THE GEARDEX OVERVIEW OVERFLOW (S10 FINDING 5). `flex:1` alone does not let a flex item
   shrink below its MIN-CONTENT width, and an alert Detail is a machine-generated exception string
   with no spaces in it ("HeadOfficeStockAdapter:NG-DICE-01-12Case:…"). So the row's min-content was
   whatever the longest token happened to be, and it pushed the whole page out: 1717px retro /
   1608px DexHQ against a 1280px viewport and 1507px against 390px, while every other page in the
   manifest sat exactly on the viewport. min-width:0 restores the shrink; overflow-wrap:anywhere is
   what actually breaks the unbreakable token once it can shrink (word-break:break-all would also
   chop ordinary prose mid-word, which reads badly on the Title line). Pre-existing, both themes. */
.quest .txt{flex:1;min-width:0;font-size:15px;overflow-wrap:anywhere}
.quest .txt small{display:block;color:var(--dim);margin-top:3px;font-size:13px;overflow-wrap:anywhere}
.quest a{color:var(--accent2);font-size:14px}

/* ===== table ===== */
table{width:100%;border-collapse:collapse;font-size:15px}
th{color:var(--dim);font-weight:600;text-align:left;padding:8px;border-bottom:var(--border-w) solid var(--line);font-size:12.5px;text-transform:uppercase;letter-spacing:1px}
td{padding:10px 8px;border-bottom:1px solid var(--hairline);vertical-align:middle}
tr:last-child td{border-bottom:none}
td.r,th.r{text-align:right}
td.c,th.c{text-align:center}
/* List-table headers stay on one line ("30d Units", "Weight (g)" wrapping ragged the header row).
   Scoped to .reporttable — the wide list screens — so narrow panel tables can still wrap. No mobile
   overflow risk: .reporttable is swapped for the card list below 700px. */
.reporttable th{white-space:nowrap}
tr.failed{background:var(--bad-tint-row)}
tr.sel{background:var(--accent-sel);outline:2px solid var(--accent)}

/* ===== buttons — console font, not pixel font (spec §2) ===== */
.btn{font-family:var(--mono);font-weight:700;letter-spacing:.8px;text-transform:uppercase;font-size:13px;color:var(--ink-dark);background:var(--accent2);border:var(--border-w) solid var(--line);border-radius:var(--radius);padding:8px 14px;cursor:pointer;box-shadow:3px 3px 0 var(--shadow);text-decoration:none;display:inline-block;line-height:1.2}
.btn:hover{filter:brightness(1.08)}
.btn:active{box-shadow:1px 1px 0 var(--shadow);transform:translate(2px,2px)}
.btn:disabled{opacity:.45;cursor:not-allowed;transform:none;box-shadow:3px 3px 0 var(--shadow)}
/* S12: INK swapped, hue untouched. --ink-light on --accent measured 3.57:1 — a WCAG AA failure on
   the app's most-used button, on every save/create screen. --ink-dark on the same purple is 5.21:1
   (the .avatar pairing, which has always shipped it). Moving --accent instead would have repainted
   active nav, links, every focus border and the whole --accent wash family; moving the ink repaints
   glyphs on one button. Miguel approved the restyle. DexHQ is unaffected — its two ink tokens hold
   the same #FFFFFF. */
.btn.primary{background:var(--accent);color:var(--ink-dark)}
/* SAVE/CREATE is purple only when there are unsaved changes; grey while the form is clean */
.btn.primary.clean{background:var(--line);color:var(--dim)}
.btn.secondary{background:var(--panel2);color:var(--text)}
.btn.good{background:var(--good);color:var(--ink-dark)}
.btn.dex{background:var(--dex);color:var(--ink-dark)}
.btn.small{font-size:11.5px;padding:4px 9px;box-shadow:2px 2px 0 var(--shadow)}

/* StationPrintButton's not-ready marker (2026-08-10). Replaces an inline sentence that used to repeat
   once per row in table loops (Shipments); this is the only on-screen signal left, so it carries the
   full Hint text as a tooltip — see the component's THE TRAP comment for why that title is on THIS
   element and not the disabled button beside it. Sized to read as unobtrusive at 50-in-a-column. */
.print-hint-marker{display:inline-block;margin-left:8px;color:var(--warn);font-size:14px;line-height:1;cursor:help;flex:none}

/* ===== split button — [ PRIMARY ][▾] (PrintSplitButton.razor) =====
   The caret is a real .btn so it inherits every themed value (fill, border width, radius, the shadow
   literals .btn/.btn.small hard-code) and cannot drift from the primary half when a theme changes.
   Only the four things that make it a JOINED caret rather than a second button are set here.

   The panel follows the .usermenu disclosure idiom exactly — transparent fixed scrim, panel ordered
   above it by DOM position, NO z-index on either. One deliberate departure: the panel is always
   rendered and hidden with display:none, instead of being conditionally rendered the way .usermenu-panel
   is, because the hover affordance below is a CSS media query and CSS cannot open a panel Blazor has not
   rendered. display:none keeps the items out of the tab order while closed, so keyboard behaviour is
   unchanged by that choice. */
.splitbtn-more{position:relative;display:inline-flex;align-items:center}
/* Butted against the primary: the shared edge is collapsed by pulling in exactly one border width
   (calc, not -2px — DexHQ's --border-w is 1px), and the caret's left corners are squared so the join
   is seamless on the retro theme, whose --radius is 0. */
.splitbtn-caret{padding:4px 7px;margin-left:calc(var(--border-w) * -1);border-top-left-radius:0;border-bottom-left-radius:0}
.splitbtn-caret:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.splitbtn-scrim{position:fixed;inset:0;background:transparent}
/* Item 4 (Packing Slip Polish, 2026-08-12) — the hover dead zone. This USED to be
   `top:calc(100% + 6px)` directly on the visible box: a 6px gap that sat OUTSIDE .splitbtn-more's own
   layout box (position:absolute children don't extend their ancestor's box), so moving the pointer from
   the caret down to the panel crossed a strip covered by no element at all. `.splitbtn-more:hover`
   momentarily went false mid-gap, `display:none` fired, and the menu vanished before the pointer ever
   reached an item — reproducing on the bench nearly every time (Miguel, 2026-08-12).

   The fix: the gap is now `padding-top` on .splitbtn-panel itself (flush at top:100%, zero space
   outside any element), so the panel's own hoverable box — a DOM descendant of .splitbtn-more, which is
   all :hover bubbling requires — covers the full path from the caret's bottom edge to the visible
   surface. The visible chrome (background/border/shadow/padding) moved to a nested
   .splitbtn-panel-surface so the bridge itself stays invisible; only its BOX needs to exist. This is
   the hover-bridge pattern — general to every disclosure with visual spacing between a hover-bound
   trigger and its panel, not special-cased to this one control. */
.splitbtn-panel{display:none;position:absolute;top:100%;right:0;padding-top:6px;min-width:190px;
  max-width:calc(100vw - 24px)}
.splitbtn-panel-surface{background:var(--panel);border:var(--border-w) solid var(--line);
  border-radius:var(--radius);box-shadow:var(--shadow-offset) 0 var(--shadow);padding:6px}
.splitbtn-more.open .splitbtn-panel{display:block}
.splitbtn-item{display:flex;align-items:center;width:100%;box-sizing:border-box;margin:0;
  padding:10px 11px;min-height:44px;border:0;background:none;font-family:var(--mono);font-size:13px;
  font-weight:700;letter-spacing:.8px;text-transform:uppercase;text-align:left;color:var(--dim);
  cursor:pointer;-webkit-appearance:none;appearance:none}
.splitbtn-item:hover{color:var(--text);background:var(--panel2)}
.splitbtn-item:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
.splitbtn-item:disabled{opacity:.45;cursor:not-allowed;background:none;color:var(--dim)}
/* Hover-to-open is a MOUSE convenience and nothing more. Scoped so it cannot reach a touch device,
   where (hover:hover) is false and a hover-only affordance would simply not exist — the bench runs a
   tablet. Click-to-open above is the real interaction on every device, and it is UNCONDITIONAL: the
   .open rule above has no media query, so a click-opened menu stays open regardless of pointer movement
   — it can only be closed by RunAsync/Close (selection, scrim click, Escape). This rule only ever ADDS
   visibility on top of that; it never takes it away.

   `:not(.shut)` is what makes hover and click coexist. Without it, clicking the caret to CLOSE the menu
   leaves the pointer sitting on the caret, so :hover immediately re-shows the panel and the control
   reads as stuck open — the close button appearing not to work. The component sets .shut on an explicit
   close and clears it when the pointer leaves, so a deliberate close wins over the hover it happened to
   be under. NOT :focus-within for the same reason, one step worse: the caret keeps focus after a click,
   so a focus-based rule would pin the panel open with no pointer movement able to clear it. */
@media (hover: hover) and (pointer: fine){
  .splitbtn-more:not(.shut):hover .splitbtn-panel{display:block}
}

/* ===== status tags ===== */
.status{font-family:var(--px);font-size:8.5px;padding:5px 8px;display:inline-block}
.s-ok{color:var(--good)} .s-run{color:var(--accent2)} .s-fail{color:var(--bad)} .s-off{color:var(--dim)}
/* THE FIFTH STATUS (2026-08-27): SET UP BUT NOT RUNNING. A capability held by a deploy-gated server
   switch, a mapping row being retired, a store whose locations were never confirmed. Deliberately
   neither .s-fail (nothing is broken) nor .s-off (which would say nothing is configured). --warn on
   --panel is the pair .hp.warn / .t-draft already put in the contrast gate — no new token, no new
   ground, so the gate needed one description edit and no new measurement. */
.s-warn{color:var(--warn)}
.src{font-size:11.5px;color:var(--dim);border:1px solid var(--line);padding:2px 7px;display:inline-block}

/* ===== run log (admin drill-down) ===== */
.runlog{font-size:13.5px;line-height:1.85;color:var(--dim);background:var(--inset);border:var(--border-w) solid var(--line);padding:12px 14px;overflow-x:auto;white-space:pre-wrap;word-break:break-word}
.runlog b{color:var(--text)}
.runlog .err{color:var(--bad)}

/* ===== filter chips ===== */
.filters{display:flex;gap:8px;margin-bottom:14px;flex-wrap:wrap}
.chip{font-size:12.5px;border:var(--border-w) solid var(--line);background:var(--panel);color:var(--dim);padding:6px 12px;text-decoration:none;cursor:pointer}
.chip.on{color:var(--text-bright);border-color:var(--accent);background:var(--panel2)}
/* Transfer lane tiers (Locations → Transfer Lanes). The cell's TEXT carries the state; colour only
   reinforces it, so the grid stays readable without colour. Ink/amber/red per the brand guide —
   Dex Pink stays reserved for AI surfaces. Pending edits are dashed: unsaved, not yet in effect. */
.chip.lane-easy{color:var(--good);border-color:var(--good)}
.chip.lane-hard{color:var(--warn);border-color:var(--warn)}
.chip.lane-block{color:var(--bad);border-color:var(--bad)}
.chip.lane-pending{background:var(--panel2);border-style:dashed;font-weight:700}

/* Grain selector + drill breadcrumb — MarketDex per-platform ads pages (MarketDex_Ads_Mockups.html
   Screens 2–3). A segmented control rather than separate chips: the three grains are one choice. */
.grain{display:inline-flex;border:var(--border-w) solid var(--line);background:var(--panel)}
.grain .grainopt{font-size:12.5px;color:var(--dim);padding:6px 12px;text-decoration:none;cursor:pointer;border-right:var(--border-w) solid var(--line)}
.grain .grainopt:last-child{border-right:none}
.grain .grainopt.on{color:var(--text-bright);background:var(--panel2);border-bottom:2px solid var(--accent)}
/* Disabled-with-reason (spec §10): still visible so the operator can see the grain EXISTS and learn from
   the tooltip why it is unavailable, rather than the option silently vanishing on one platform. */
.grain .grainopt.disabled{opacity:.4;cursor:not-allowed;text-decoration:line-through}
.crumb{font-size:12.5px;color:var(--dim);border:var(--border-w) dashed var(--line);padding:5px 10px;background:var(--panel)}
.crumb b{color:var(--text-bright)}
.crumb .x{color:var(--bad);margin-left:8px;text-decoration:none;cursor:pointer;font-weight:700}

/* Status / learning / flag badges. .badge supplies the pixel type; these supply only the semantics. */
.badge.b-live{color:var(--good);border:var(--border-w) solid var(--good)}
.badge.b-paused{color:var(--dim);border:var(--border-w) solid var(--line)}
.badge.b-learn{color:var(--warn);border:var(--border-w) solid var(--warn)}
.badge.b-limited{color:var(--bad);border:var(--border-w) solid var(--bad)}
.badge.b-fatigue{color:var(--bad);border:var(--border-w) solid var(--bad)}

/* A paused row still reports its spend, so it is dimmed rather than hidden. */
tr.rowmuted td{opacity:.55}
tr.rowsel td{background:var(--panel2)}
th.sortable a{color:inherit;text-decoration:none;cursor:pointer}
th.sorted a{color:var(--accent)}
/* Horizontal scroll for the wide grain tables at narrow widths (spec §10 mobile rule). */
.tblwrap{overflow-x:auto}

/* ===== form fields ===== */
.field{margin-bottom:14px}
.field label{display:block;font-size:12.5px;color:var(--dim);text-transform:uppercase;letter-spacing:1px;margin-bottom:6px}
.field input,.field select,.field textarea{width:100%;background:var(--inset);border:var(--border-w) solid var(--line);color:var(--text);font-family:var(--mono);font-size:15px;padding:9px 11px;outline:none}
.field input:focus,.field select:focus,.field textarea:focus{border-color:var(--accent)}
.frow{display:grid;grid-template-columns:1fr 1fr;gap:12px}

/* ===== empty / placeholder state ===== */
.placeholder{padding:60px 22px;text-align:center;color:var(--dim)}
.placeholder .big{font-family:var(--px);font-size:15px;color:var(--accent);margin-bottom:14px}
.placeholder .lock{font-size:34px;margin-bottom:14px}

/* ===== sign-in screen ===== */
.signin-wrap{min-height:100vh;display:grid;place-items:center;padding:20px}
.signin{width:min(420px,100%);padding:34px 30px;text-align:center}
.signin .logo{width:auto;padding:0;margin-bottom:24px;display:flex;justify-content:center}
.signin .logo img{height:34px;width:auto}
/* same 34px slot for the lockup — 34px box ⇒ ~155px wide, comfortably over the guide's ~90px floor */
.signin .logo .wordmark{--wm-box:34px}
.signin .tag{color:var(--dim);font-size:18px;margin-bottom:26px}
.signin .err{color:var(--bad);font-size:13.5px;margin-bottom:16px;border:var(--border-w) solid var(--bad);padding:10px}

/* ================= MOBILE (≤840px) ================= */
.tabbar{display:none}
@media (max-width:840px){
  .frame{grid-template-columns:1fr}
  .sidenav{display:none}
  .grid2{grid-template-columns:1fr}
  .logo{width:auto;justify-content:flex-start;padding-left:calc(12px + env(safe-area-inset-left))}
  .logo img{height:21px}
  /* 21px box ⇒ ~96px wide. The brand guide's minimum for the FULL lockup is ~90px ("below that,
     use the star icon alone"), so the phone topbar clears it — narrowly, and on purpose: dropping
     to the star alone at the app's most-used width would cost the wordmark. If a future size lands
     under 90px it must switch to the star, not shrink. */
  .logo .wordmark{--wm-box:21px}
  .userchip .uname{display:none}
  /* UI-1 — THE MODULE SWITCHER STOPS BEING A SCROLLING STRIP. .modules is display:flex with
     overflow-x:auto, so at 390px it was six pixel-font tabs in a ~236px box: a sideways-scrolling
     nav, which is what Miguel called "very odd". The strip goes away entirely at this width and
     .modmenu (declared display:none up in the topbar section) takes its slot. Desktop keeps the
     strip exactly as it was — this pair of lines is the whole of the scoping. */
  .modules{display:none}
  .modmenu{display:flex}
  .content,.content-wide{padding:14px 12px 100px}
  /* Blocker 1 (2026-08-08 fix cycle): the 100px above is reserved to clear the FIXED .tabbar two rules
     down. When ShellChromeState.SuppressMobileNav is set, MainLayout stops rendering .tabbar but does
     NOT stop reserving its space unless something here says so — this is that something. Scoped to the
     wrapper class (not a change to the rule above) so every OTHER page keeps the ordinary 100px gutter;
     only a page that actually suppresses the shell bar gets it back. Bottom drops to match the existing
     top/side padding, not to 0 — the page still wants a breathing margin from the viewport edge, it
     just no longer needs to clear a bar that isn't there. */
  .chrome-suppressed .content,.chrome-suppressed .content-wide{padding-bottom:14px}
  /* bottom bar = ACTIVE MODULE's sections + MORE */
  .tabbar{display:flex;position:fixed;bottom:0;left:0;right:0;background:var(--panel);border-top:var(--border-w) solid var(--line);justify-content:space-around;padding:8px 2px calc(10px + env(safe-area-inset-bottom));z-index:20}
  /* S12 — THE MOBILE TAB BAR (S10 FINDING 5b). At 390px the bar is five flex boxes of ~77px. Press
     Start 2P has a FIXED 1em advance per glyph, so a label costs exactly (chars x font-size): at
     the old 10.5px "SHIPMENTS" was 94px, "EXCEPTIONS" 105px and "MODULE SETTINGS" 157px in a 73px
     content box. The label was a bare text node with no wrapping opportunity, so it simply painted
     past its box and OVER its neighbours — three labels colliding, unreadable. Three changes, all
     needed together: the label got its own element (MainLayout), it drops to the 8px PS2P GRID SIZE
     the shell already uses for .tag (on-grid is CRISPER than 10.5px, not smaller-and-blurrier), and
     it is allowed to wrap — at spaces first, then anywhere, so a long single word breaks instead of
     escaping. overflow:hidden on the anchor is the backstop that makes overprinting IMPOSSIBLE for
     any future label rather than merely unlikely. DexHQ's --px is proportional Outfit and was
     already legible; it inherits the same rules and simply has more room.

     UI-2 RE-AIMED THE SELECTOR, NOT THE RULES: `.tabbar a` became `.tabbar .tab`. It had to. MORE is
     now a disclosure whose panel renders REAL anchors INSIDE .tabbar (that is how it paints above
     the bar without inventing a z-index — see .moremenu-panel), and `.tabbar a` would have laid
     every one of those out as a 20px column-flex tab. The declarations below are S12's, unchanged. */
  .tabbar .tab{color:var(--dim);text-decoration:none;font-family:var(--px);display:flex;flex-direction:column;align-items:center;gap:5px;text-align:center;min-width:0;flex:1;overflow:hidden}
  .tabbar .tab .tlab{font-size:8px;line-height:1.45;letter-spacing:0;overflow-wrap:anywhere;max-width:100%}
  .tabbar .tab .tico{font-size:20px;font-family:var(--mono)}
  .tabbar .tab .pix{width:20px;height:20px}
  .tabbar .tab.active{color:var(--accent)}

  /* ---- UI-2: MORE, AS A DISCLOSURE ----
     .moremenu is the flex COLUMN in the bar (it replaces one tab slot); .moremenu-trigger carries
     `tab` too, so it inherits every S12 rule above and is laid out identically to its neighbours —
     the bar is still five equal columns.

     STACKING, CHECKED RATHER THAN PICKED. The panel is a child of .tabbar, which is
     position:fixed;z-index:20 — already a stacking context, and the highest in the shell except
     .gdmodal-back/.modalwrap (200), .lightbox (2000) and the CRT scanline overlay (99, which is
     pointer-events:none and paints over everything by design). So the panel needs NO z-index of its
     own: being inside .tabbar puts it above page content and above the topbar's 10, and
     bottom:100% floats it clear of the bar. Nothing new was invented to make that true. */
  .moremenu{display:flex;flex:1;min-width:0}
  .moremenu-trigger{margin:0;padding:0;border:0;background:none;font:inherit;cursor:pointer;
    -webkit-appearance:none;appearance:none}
  .moremenu-trigger:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
  /* 20px to match the .pix icons in the neighbouring tabs, so the row of marks sits on one rhythm. */
  .barsmark-tab{width:20px;height:14px}
  .moremenu-scrim{position:fixed;inset:0;background:transparent}
  .moremenu-panel{position:absolute;bottom:100%;left:0;right:0;margin:0 8px 8px;
    background:var(--panel);border:var(--border-w) solid var(--line);border-radius:var(--radius);
    box-shadow:var(--shadow-offset) 0 var(--shadow);padding:6px;
    /* GearDex's remainder is nine rows (~44px each). This keeps the tallest case inside a short
       viewport instead of running off the top of the screen. */
    max-height:calc(100vh - 150px);overflow-y:auto}
  .moremenu-head{font-family:var(--px);font-size:9px;color:var(--dim);letter-spacing:1px;
    padding:8px 10px 6px;margin:0}
  /* Deliberately the .sidenav a shape — 14px body font, 16px icon, accent rail when active — and NOT
     the tab bar's 8px PS2P. These are SECTION labels ("Analytics Pushes", "Receive / Count"), the
     same strings the sidebar draws at desktop; setting them in a pixel font with a fixed 1em advance
     is what put S12 in the position of fixing overprinting in the first place. */
  .moremenu-item{display:flex;align-items:center;gap:10px;width:100%;box-sizing:border-box;margin:0;
    padding:10px 11px;border:0;border-left:3px solid transparent;background:none;
    font-family:var(--mono);font-size:14px;text-align:left;text-decoration:none;color:var(--dim);
    cursor:pointer;overflow-wrap:anywhere}
  .moremenu-item:hover{color:var(--text);background:var(--panel2)}
  .moremenu-item:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
  .moremenu-item.active{color:var(--text-bright);background:var(--panel2);border-left-color:var(--accent)}
  .moremenu-sep{border-top:var(--border-w) dashed var(--line);margin:8px 6px}
}

/* ================= GearDex Products / Campaigns screens ================= */
/* (spec §6 archetypes; mockups NerdStack_Mock_Products.html + NerdStack_Mock_Campaigns.html) */

/* lifecycle / status tags — pixel font. products: active/draft/review/arch · campaigns: live/plan/done/kill */
.tag{font-family:var(--px);font-size:10px;padding:4px 7px;display:inline-block}
.t-active{color:var(--good)} .t-draft{color:var(--warn)} .t-review{color:var(--accent2)} .t-arch{color:var(--dim)}
.t-live{color:var(--good)} .t-plan{color:var(--accent2)} .t-done{color:var(--dim)} .t-kill{color:var(--bad)}
.vbadge{font-size:12px;color:var(--accent2);border:1px solid var(--line);padding:2px 8px;background:var(--inset)}
.vbadge.single{color:var(--dim)}
.brand{font-size:12px;color:var(--dim)}
/* generic ink-only .tag modifiers (Admin Users & Roles: "no roles" / shelved chip / money-set chip) —
   same ink-only shape as .t-active/.t-draft/…, just named for what they mean outside a lifecycle. */
.tag.warn{color:var(--warn)}
.tag.muted{color:var(--dim)}
.tag.money{color:var(--warn)}

/* filter dropdowns */
.fsel{font-family:var(--mono);font-size:12.5px;border:var(--border-w) solid var(--line);background:var(--panel);color:var(--text);padding:6px 10px;outline:none}
.fsel:focus{border-color:var(--accent)}

/* clickable list rows */
tr.clickable:hover{background:var(--panel2);cursor:pointer}

/* PO detail: recorded payments as cards (readable in the narrow right column, vs a cramped table) */
.paycards{display:flex;flex-direction:column;gap:8px;margin-top:6px}
.paycard{border:var(--border-w) solid var(--line);background:var(--inset);padding:10px 12px}
.paycard-top{display:flex;justify-content:space-between;align-items:baseline;gap:8px}
.paycard-amt{font-family:var(--mono);font-size:15px;color:var(--text);font-weight:bold}
.paycard-actions{display:flex;gap:12px;flex:none}
.paycard-actions .pencil{cursor:pointer;color:var(--warn);font-size:14px}
.paycard-meta{font-size:12px;color:var(--dim);margin-top:4px}
.paycard-note{font-size:12.5px;color:var(--text);opacity:.8;margin-top:6px;word-break:break-word}
/* detail two-column (wider left) */
.pdetail{display:grid;grid-template-columns:1.35fr .65fr;gap:14px}
/* true 50/50 two-panel row (GearDex Issue Backlog Aug-15 — Kit/MTO panels). min-width:0 on the
   children is load-bearing, not decorative: a grid track's implicit min-width:auto lets its content
   (the KIT table's non-breaking SKUs, e.g. VIS-CMP-01) demand more than half the row, which measured
   as a ~2:1 KIT:MTO split instead of 50/50 — the same trap documented below for .pdetail on mobile,
   hit here on desktop because the KIT table is the wide content, not a SKU cell. */
.duo2{display:grid;grid-template-columns:1fr 1fr;gap:14px;align-items:start}
.duo2 > *{min-width:0}
/* customs/dims validation hint (Products detail) */
.field .hint{display:block;font-size:11.5px;color:var(--dim);margin-top:5px}
.field input:invalid{border-color:var(--bad)}
.field input:invalid + .hint{color:var(--bad)}
/* editable product-title header (✎) */
.edithead{display:flex;align-items:center;gap:8px}
.edithead .pencil{font-size:16px;color:var(--warn);cursor:pointer;background:none;border:none;padding:0}
.edithead input{background:var(--inset);border:var(--border-w) solid var(--line);color:var(--text);font-family:var(--mono);font-size:16px;padding:6px 10px;flex:none;width:min(540px,calc(100vw - 220px))}
.edithead .ponum{flex:none;white-space:nowrap}
/* inline-editable variant price cell */
/* Base: .priceedit was only ever defined under .vtable, so the same class on a modal input (the
   transfer confirms) fell back to the raw browser default — an unstyled grey box in a retro panel.
   Centred here; the in-table variant below re-aligns right to sit under its numeric column. */
input.priceedit{width:74px;background:var(--inset);border:var(--border-w) solid var(--line);color:var(--text);
  font-family:var(--mono);font-size:14px;padding:5px 8px;text-align:center}
input.priceedit:focus{border-color:var(--accent);outline:none}
.vtable input.priceedit{text-align:right}
/* lightweight retro modal (transfer / push confirm / add variant) */
.gdmodal-back{position:fixed;inset:0;background:var(--scrim-gdmodal);z-index:200;display:grid;place-items:center;padding:16px}
.gdmodal{background:var(--panel);border:var(--border-w) solid var(--accent);box-shadow:6px 6px 0 var(--shadow);padding:18px 20px;max-width:440px;width:100%}
.gdmodal h3{font-family:var(--px);font-size:11.5px;color:var(--accent2);margin-bottom:14px}
.gdmodal .actions{display:flex;gap:8px;justify-content:flex-end;margin-top:16px;flex-wrap:wrap}
.gdmodal p.err{color:var(--bad);font-size:12.5px;margin-top:8px}
/* Admin Users & Roles: the role editor's grouped checkboxes need more room than a confirm dialog —
   a wide variant of the same modal shape, not a second one. Scrolls internally so a tall group list
   (four groups, ~20 sets) never pushes the Save/Cancel row off a short viewport. */
.gdmodal.wide{max-width:640px;max-height:calc(100vh - 64px);overflow-y:auto}
th.sortable{cursor:pointer;user-select:none}
.reporttable.nowrapth th{white-space:nowrap}
th.sortable:hover{color:var(--text)}
/* Expand/collapse row toggles (▸/▾) — enlarged globally for a clearer click affordance. */
.expander{font-size:22px;line-height:1;vertical-align:-3px}
/* ===== responsive list pattern (≤700px): table → tappable cards ===== */
.cardlist{display:none}
@media(max-width:700px){
  .reporttable{display:none}
  .cardlist{display:flex;flex-direction:column;gap:10px}
}
.rcard{display:block;background:var(--panel2);border:var(--border-w) solid var(--line);box-shadow:3px 3px 0 var(--shadow);padding:12px 14px;text-decoration:none;color:var(--text)}
.rcard:active{background:var(--panel)}
.rcard .top{display:flex;justify-content:space-between;align-items:flex-start;gap:10px}
.rcard .sku{font-weight:700;color:var(--accent2)}
.rcard .name{display:block;font-size:12.5px;color:var(--dim);margin-top:2px;font-weight:400}
.rcard .metrics{margin-top:9px;font-size:14px}
.rcard .metrics b{color:var(--text)}
.rcard .metrics .lbl{color:var(--dim)}
.rcard .meta{margin-top:6px;font-size:12px;color:var(--dim)}
/* ===== shared order list: Orders hub + Ready to Ship (Table Sync cut, 2026-08-11) =====
   The order cell's `sub` line carries the PLACED DATE. In the table it becomes a block via
   `.skucell .sub`; a card has no .skucell, so without this the date runs inline into the TEST badge
   and reads as part of the order number. Same shape as `.rcard .name`, which it sits beside. */
.rcard .top .sub{display:block;font-size:12px;color:var(--dim);margin-top:2px;white-space:nowrap}
/* An order number and an ISO date are ONE token to a human and full of break opportunities to a
   browser: with eleven columns sharing the width it broke "#VIS-ORD-02" into "#VIS-" / "ORD-02" and
   "2026-07-13" into "2026-07-" / "13". Scoped above 840px DELIBERATELY — below it the
   `.skucell a,.skucell .sub{overflow-wrap:anywhere}` rule is what lets these tables shrink at all
   (see the S12 note there), and overriding it would trade a cosmetic break for a real overflow. */
@media(min-width:841px){
  .odxlist td.skucell a,.odxlist td.skucell .sub{white-space:nowrap}
}
/* Card that navigates on tap but carries its own controls (buy-list select) — mirrors tr.sel */
.rcard.tap{cursor:pointer}
.rcard.sel{background:var(--accent-sel);outline:2px solid var(--accent)}
.rcard .pick{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--dim)}
/* On-hand mobile: per-location breakdown line inside the card */
.rcard .locs{margin-top:8px;font-size:12.5px;color:var(--dim);line-height:1.6}
.rcard .locs b{color:var(--text)}
/* Wide matrix (desktop): freeze the SKU column, scroll the numeric columns */
.matrixwrap{overflow-x:auto}
.matrixwrap table{min-width:640px}
.matrixwrap th.stick,.matrixwrap td.stick{position:sticky;left:0;background:var(--panel);z-index:1}
.matrixwrap tr:hover td.stick{background:var(--panel2)}
/* UI-7 — ALTERNATING COLUMN BANDS. Miguel: "Could you add some alternating column background
   colours to large grids like this? Just a subtle colour change is enough." COLUMN, not row: the
   readability problem in a wide numeric matrix is tracking ACROSS many similar columns, which is a
   different job from the row washes this theme already has.

   nth-child, NOT <colgroup><col>. Three reasons, in order of weight: (1) a <col> background paints
   BELOW cell backgrounds, so the day a row wash lands on this table the band would vanish silently
   rather than compose — a failure mode you cannot see in review; (2) a colgroup has to be emitted
   with exactly LocationCodes.Count + 2 entries, i.e. a second place that must track the data; (3)
   nth-child needs no markup at all, so turning banding on for another wide report is one class.

   ---- .colband IS ITS OWN CLASS, AND THAT SEPARATION IS THE POINT (UI-7b) ----
   Banding first shipped welded to .matrixwrap. But .matrixwrap means "freeze the label column and
   scroll the rest": it also carries overflow-x:auto, a 640px table min-width and a sticky first
   column. Miguel then asked for banding on the other Stock reports — and putting .matrixwrap on them
   to get it would have dragged all of that along and changed their scroll behaviour, which is a
   regression wearing a style change's clothes. So the two concerns are separate classes that
   COMPOSE: On-Hand is `matrixwrap reporttable colband`, because its location columns really are
   dynamic and really do need freezing; the other four are `reporttable colband` and keep the plain
   layout they already have.

   ---- HOW THE LABEL COLUMN IS EXCLUDED, EXPLICITLY ----
   By naming what SHOULD band rather than what should not. `.r` is the shell's right-aligned
   (numeric) cell class, which every numeric <td> and every `SortableTh Numeric="true"` already
   emits. So the label column is excluded for TWO INDEPENDENT reasons — it is child 1 (odd) and it is
   not `.r` — and neither of them is `.stick`, which exists only on the one table that freezes. A
   label column would have to become a right-aligned numeric column to band, at which point it is not
   a label. The previous `:not(.stick)` form became WRONG the moment banding left .matrixwrap: with
   no .stick in the markup it matches everything, so the label column would have banded.

   Bands the EVEN children among those, i.e. alternate numeric columns. On On-Hand that is cells
   2,4,6,8 — exactly what the old rule produced, which is why this refactor leaves that page's
   pixels untouched and gives the change a built-in control.

   COMPOUNDING: a banded column crossing a washed row would stack two translucent layers and land
   under the floor (two .04 accent layers over DexHQ white measure 4.30 against a 4.43 floor). No
   .colband table carries a row wash today — every <tr> in all five is bare, checked row by row — and
   ContrastGateTests.No_banded_table_carries_a_row_wash_because_bands_and_washes_compound keeps it
   that way, so this is a caught condition rather than a hoped-for one. */
.colband tr > .r:nth-child(even){background:var(--col-band)}

/* list thumbnail with hover-expand */
.thumbwrap{position:relative;display:inline-block}
.thumb{width:44px;height:44px;object-fit:cover;border:var(--border-w) solid var(--line);background:var(--inset);display:block}
.thumb.ph{display:grid;place-items:center;color:var(--dim);font-size:10px;font-family:var(--px)}
.thumbbig{display:none;position:absolute;left:56px;top:-30px;width:230px;height:230px;object-fit:cover;border:var(--border-w) solid var(--accent);box-shadow:6px 6px 0 var(--shadow);z-index:50;background:var(--inset)}
.thumbwrap:hover .thumbbig{display:block}

/* hover/tap popups (on-hand by warehouse / velocity / QuantityPopover — Components/Shared).
   :focus + :focus-within reveal the same popup as :hover with NO JS: a tabindex=0 wrapper receives
   focus on a mobile tap (touch ⇒ tap, item 6, OrderDex Polish Batch 1 2026-08-09), the same as a
   mouse hover on desktop — one CSS rule set, no touchstart/touchend interop needed. */
.ohwrap{position:relative;display:inline-block;cursor:help;border-bottom:1px dotted var(--dim)}
.ohpop{display:none;position:absolute;right:0;top:24px;z-index:60;background:var(--panel);border:var(--border-w) solid var(--accent);box-shadow:6px 6px 0 var(--shadow);padding:10px 12px;min-width:190px;text-align:left}
.ohwrap:hover .ohpop,.ohwrap:focus .ohpop,.ohwrap:focus-within .ohpop{display:block}
/* ESCAPE A SCROLLING ANCESTOR (OrderDex item 5, 2026-08-11). Opt-in via QuantityPopover Fixed="true".
   The order-detail line table is `.odx-group .reporttable{overflow-x:auto}` — a scrolling ancestor, which
   CLIPS an absolutely-positioned popover and widens the container's scroll extent. position:fixed is
   viewport-relative, so no ancestor can clip it; js/nerd-popover.js supplies the coordinates (right/top are
   cleared here because they would otherwise pin it to the viewport corner). Opt-IN rather than global: the
   GearDex product-list popover shares this component and is unaffected by design. */
/* width:max-content is REQUIRED, not tidying. Out of flow against the VIEWPORT (rather than the
   inline-block wrapper), width:auto resolved to the full viewport width — so the popup stretched edge
   to edge AND the script's `left = anchor.right - width` went negative and clamped to the left margin.
   Sizing to content fixes the box and the placement together; max-width keeps a long location name on
   screen at phone width. */
.ohwrap.fixedpop .ohpop{position:fixed;right:auto;top:auto;z-index:80;width:max-content;max-width:min(280px,calc(100vw - 12px))}
.ohpop table{font-size:12.5px}
.ohpop td{padding:3px 6px;border-bottom:1px dashed var(--hairline)}
.ohpop tr:last-child td{border-bottom:none;color:var(--accent2);font-weight:700}
.ohpop .own{color:var(--good);font-size:10px}

/* media gallery */
.gallery{display:flex;gap:10px;flex-wrap:wrap;align-items:flex-start}
.gmain{width:200px;height:200px;object-fit:cover;border:var(--border-w) solid var(--line);background:var(--inset)}
/* Variant media card: the variant image and the product image side by side, actions under the
   variant one only (the product image is not editable from here). */
.vmedia{display:flex;gap:14px;flex-wrap:wrap}
.vslot{display:flex;flex-direction:column;gap:6px;width:150px}   /* pin to the image: otherwise the buttons/label widen the slot and .vmedia wraps */
.vslotlabel{font-family:var(--px);font-size:8px;letter-spacing:1px;color:var(--accent2);white-space:nowrap}
.vslotlabel.dim{color:var(--dim)}
.vimgtools{display:flex;gap:6px;margin-top:8px}
/* Icon-only actions — bigger tap target than .btn.small, and two of them can never wrap out of
   the 150px slot the way the text buttons did. */
.ibtn{width:36px;height:36px;display:grid;place-items:center;padding:0;line-height:1;font-size:16px;
  font-family:var(--mono);color:var(--dim);background:var(--panel2);border:var(--border-w) solid var(--line);cursor:pointer}
.ibtn:hover{color:var(--accent);border-color:var(--accent)}
.ibtn.bad:hover{color:var(--bad);border-color:var(--bad)}
.ibtn:disabled{opacity:.35;cursor:not-allowed;color:var(--dim);border-color:var(--line)}
/* 2-column grid of secondary images, each its own hover-controlled cell. */
.gstrip{display:grid;grid-template-columns:repeat(2,72px);gap:8px;align-content:start}
.gcell{position:relative;width:72px;height:72px;line-height:0}
.gcell.primary .gthumb{border-color:var(--accent)}
.gthumb{width:72px;height:72px;object-fit:cover;border:var(--border-w) solid var(--line);background:var(--inset);cursor:zoom-in;display:block}
/* Marks position 0 without needing a legend — this is the list thumbnail + Shopify push source.
   DELIBERATE LITERAL (#000): this halo sits over arbitrary product PHOTOGRAPHY, not over a theme
   ground. Its job is to keep the ★ readable against whatever pixels are underneath, which does not
   change with the theme. Do not tokenize. */
.gprimary{position:absolute;top:1px;left:3px;color:var(--warn);font-size:11px;line-height:1;pointer-events:none;text-shadow:0 0 3px #000,0 0 3px #000}
/* Per-thumbnail controls: a bar across the BOTTOM only, so the rest of the cell stays a lightbox
   target. Revealed on hover; also on focus-within so keyboard users can reach them. */
.gcelltools{position:absolute;left:2px;right:2px;bottom:2px;display:none;background:var(--scrim-heavy);border-top:var(--border-w) solid var(--line)}
.gcell:hover .gcelltools,.gcell:focus-within .gcelltools{display:flex}
.gcelltools button{flex:1;min-width:0;background:none;border:none;color:var(--dim);font-family:var(--mono);
  font-size:10px;line-height:1;padding:4px 0;cursor:pointer}
.gcelltools button:hover:not(:disabled){color:var(--accent);background:var(--accent-press)}
.gcelltools button.bad:hover:not(:disabled){color:var(--bad);background:var(--bad-press)}
.gcelltools button:disabled{opacity:.3;cursor:not-allowed}
.gadd{width:72px;height:72px;border:var(--border-w) dashed var(--line);color:var(--dim);display:grid;place-items:center;font-size:11px;font-family:var(--px);cursor:pointer;text-decoration:none}
/* Click-to-enlarge affordance on a gallery main image (Lightbox.razor). */
.gmain.zoom{cursor:zoom-in}
.lightbox{position:fixed;inset:0;z-index:2000;display:grid;place-items:center;padding:24px;
  background:var(--scrim-heavy);cursor:zoom-out}
.lightbox img{max-width:min(92vw,640px);max-height:88vh;object-fit:contain;background:var(--inset);
  border:var(--border-w) solid var(--accent);box-shadow:8px 8px 0 var(--shadow)}
.lbclose{position:absolute;top:18px;right:22px}

/* variants inline-edit table */
.vtable input{background:var(--inset);border:var(--border-w) solid var(--line);color:var(--text);font-family:var(--mono);font-size:14px;padding:5px 8px}

/* dashed section divider */
.divider{border:none;border-top:var(--border-w) dashed var(--line);margin:34px 0 26px}

/* variant switcher list (variant edit page) */
.vswitch{max-height:250px;overflow-y:auto;border:var(--border-w) solid var(--line);background:var(--inset)}
.vswitch a{display:flex;justify-content:space-between;padding:8px 11px;color:var(--dim);text-decoration:none;font-size:13.5px;border-top:1px dashed var(--hairline)}
.vswitch a:first-child{border-top:none}
.vswitch a:hover{color:var(--text);background:var(--panel2)}
.vswitch a.active{color:var(--text-bright);background:var(--panel2);border-left:3px solid var(--accent)}

/* ---- Buy List: tabs, engine-run chip, class tags, two-line SKU cell ---- */
.tabs{display:flex;gap:2px;margin-bottom:14px;flex-wrap:wrap}
.tabs a{font-family:var(--mono);font-weight:700;letter-spacing:.6px;font-size:13.5px;color:var(--dim);text-decoration:none;padding:10px 14px;border:var(--border-w) solid var(--line);border-bottom:none;background:var(--panel);cursor:pointer}
.tabs a.on{color:var(--text-bright);background:var(--panel2);border-color:var(--accent)}
/* ===== marketing tab: tag chips, feature bullets, channel listings ===== */
.tagchip{display:inline-block;font-size:12.5px;border:1px solid var(--line);background:var(--inset);color:var(--text);padding:3px 9px;margin:0 5px 6px 0}
.tagchip i{font-style:normal;color:var(--dim);margin-left:5px;cursor:pointer}
.tagchip i:hover{color:var(--bad)}
.tagadd{display:flex;gap:6px;margin:2px 0 4px}
.tagadd input{flex:1;background:var(--inset);border:var(--border-w) solid var(--line);color:var(--text);font-family:var(--mono);font-size:13.5px;padding:6px 9px;outline:none}
.tagadd input:focus{border-color:var(--accent)}
.blt{display:flex;gap:8px;align-items:center;margin-bottom:7px}
.blt input{flex:1;background:var(--inset);border:var(--border-w) solid var(--line);color:var(--text);font-family:var(--mono);font-size:14px;padding:7px 10px;outline:none}
.blt input:focus{border-color:var(--accent)}
.blt a{color:var(--bad);text-decoration:none;font-size:14px}
.chl{border:var(--border-w) solid var(--line);background:var(--inset);padding:10px 12px;margin-bottom:10px}
.chl .chead{display:flex;align-items:center;gap:10px;margin-bottom:6px}
.chl .cname{font-family:var(--px);font-size:9px;color:var(--text-bright)}
.chl .cmeta{font-size:12.5px;color:var(--dim);line-height:1.55}
.chl .cmeta b{color:var(--text);font-weight:600}
/* ===== DEX✦ DESCRIBE draft state (pink) ===== */
.dexdraft-bar{display:flex;align-items:center;gap:10px;border:var(--border-w) solid var(--dex);background:var(--dex-draft-bg);color:var(--dex-text);font-size:12.5px;padding:8px 11px;margin-bottom:12px}
.dexdraft-bar b{color:var(--dex-text)}
.panel.card.dexdraft .field input,.panel.card.dexdraft .field textarea{border-color:var(--dex)}
.panel.card.dexdraft .tagchip{border-color:var(--dex)}
.runchip{margin-left:auto;font-size:12.5px;color:var(--dim);border:var(--border-w) solid var(--line);padding:5px 11px;background:var(--panel);white-space:nowrap}
.runchip b{color:var(--good)}
.topup{font-family:var(--px);font-size:8px;color:var(--warn)}
.t-a{color:var(--bad)} .t-b{color:var(--warn)} .t-c{color:var(--dim)}
/* two-line SKU cell (locked table standard 2026-07-12) */
.skucell a{color:var(--accent2);text-decoration:none}
.skucell .sub{display:block;font-size:12px;color:var(--dim);margin-top:2px}

/* ---- Overview: freshness banner + drill-through KPI tiles + WoW deltas ---- */
/* S12 added flex-wrap. The bar is [dot][sentence][RUN ENGINE NOW, pinned right by margin-left:auto]
   and the button is flex:none — so on a phone the sentence could not shrink past its min-content and
   the bar's own min-content (452px retro / 426px DexHQ) became the widest thing on the page, once
   the alert rows stopped being. This is the SECOND cause of S10 FINDING 5's Overview overflow, and
   it was invisible until the first one was fixed. Wrapping costs nothing at 1280 — the bar fits on
   one line there and the 1280 captures come back byte-identical; at 390 the button takes its own
   line. */
.freshness{display:flex;flex-wrap:wrap;align-items:center;gap:12px;background:var(--panel);border:var(--border-w) solid var(--good);padding:10px 14px;margin-bottom:16px;font-size:13.5px}
.freshness.stale{border-color:var(--warn)} .freshness.bad{border-color:var(--bad)}
.freshness .dot{width:10px;height:10px;background:var(--good);flex:none}
.freshness.stale .dot{background:var(--warn)} .freshness.bad .dot{background:var(--bad)}
.freshness .runnow{margin-left:auto;flex:none;background:none;border:var(--border-w) solid var(--dex);color:var(--dex-text);font:inherit;font-size:12px;padding:4px 10px;cursor:pointer;letter-spacing:.5px}
/* hover inverts to a --dex FILL. The label was --panel (#1b1b28), which measured 5.02:1 on the
   brand pink — under the 5.27 floor. --ink-dark is the token that already answers "dark ink on a
   bright fill" (.btn.dex uses it on the same ground) and measures 5.49:1. */
.freshness .runnow:hover:not(:disabled){background:var(--dex);color:var(--ink-dark)}
.freshness .runnow:disabled{border-color:var(--dim);color:var(--dim);cursor:default}
.kpi .sub2{font-size:12px;color:var(--dim);margin-top:4px}
/* Clickable panels (dashboard KPI tiles + Reports cards): hand cursor, hover lift, blue GO marker. */
a.panel{display:block;text-decoration:none;color:inherit;position:relative;cursor:pointer}
a.panel:hover{background:var(--panel2);border-color:var(--accent2)}
a.panel .go{position:absolute;top:11px;right:13px;font-size:11px;color:var(--accent2)}
.d-up{color:var(--good)} .d-dn{color:var(--bad)} .d-flat{color:var(--dim)}
.bar .fill.cash{background:repeating-linear-gradient(90deg,var(--accent) 0 8px,var(--stripe-accent) 8px 10px)}

/* ---- Paula's mobile Receive screen (390px phone frame) ---- */
.phone{width:390px;max-width:100%;border:3px solid var(--line);box-shadow:8px 8px 0 var(--shadow);margin:0 auto;background:var(--bg)}
.ptop{background:var(--panel);border-bottom:var(--border-w) solid var(--line);padding:12px;display:flex;justify-content:space-between;align-items:center}
.ptop .t{font-family:var(--px);font-size:11px;color:var(--text-bright)}
.pbody{padding:12px}
.pfield{margin-bottom:11px}
.pfield label{display:block;font-size:11.5px;color:var(--dim);text-transform:uppercase;letter-spacing:1px;margin-bottom:5px}
.pfield input,.pfield select{width:100%;background:var(--inset);border:var(--border-w) solid var(--line);color:var(--text);font-family:var(--mono);font-size:16px;padding:11px;outline:none}
.pfield input:focus,.pfield select:focus{border-color:var(--accent)}
/* SKU type-ahead dropdown (2026-08-07 Receive polish) — sized to the 390px phone frame, not the
   480px-min-width .vpick-menu (that one's for desktop-ish shipment-line forms). Thumb-usable rows. */
.sku-field{position:relative}
.sku-suggest{position:absolute;left:0;right:0;top:100%;z-index:50;background:var(--inset);border:var(--border-w) solid var(--accent);border-top:none;max-height:260px;overflow-y:auto;box-shadow:var(--shadow-offset) 0 var(--shadow)}
.sku-suggest-item{display:flex;flex-direction:column;gap:1px;padding:10px 12px;cursor:pointer;border-bottom:1px solid var(--line);min-height:44px;justify-content:center}
.sku-suggest-item:last-child{border-bottom:none}
.sku-suggest-item:hover,.sku-suggest-item.active{background:var(--panel2)}
.sku-suggest-item.active{box-shadow:inset 3px 0 0 var(--accent)}
.sku-suggest-sku{font-family:var(--mono);font-size:15px;color:var(--text);font-weight:600}
.sku-suggest-name{font-size:12.5px;color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
/* Camera scan (2026-08-07). The button sits INSIDE the SKU field's relative box; the input keeps its
   full width and gains right padding so typed text can never run under the glyph. 44px = the touch
   target the polish build sized the rest of this screen to. */
.sku-field input{padding-right:52px}
.sku-scan-btn{position:absolute;right:0;bottom:0;width:44px;height:44px;display:flex;align-items:center;justify-content:center;background:none;border:none;font-size:20px;line-height:1;cursor:pointer;color:var(--text)}
.sku-scan-btn:active{background:var(--panel2)}
.sku-scan-note{margin-top:5px;font-size:11.5px;color:var(--dim)}
/* SKU clear ✕ (2026-08-08 Receive mobile QoL). Same 44px touch target as the camera glyph, mirrored
   on the same absolute-positioned pattern — it sits at right:0 alone, or shifts to right:44px
   (.with-scan) so it never overlaps the camera button when both render. .dual-btn widens the input's
   own reserved padding for that two-button case; the camera-only 52px above is untouched. */
.sku-clear-btn{position:absolute;right:0;bottom:0;width:44px;height:44px;display:flex;align-items:center;justify-content:center;background:none;border:none;font-size:16px;line-height:1;cursor:pointer;color:var(--dim)}
.sku-clear-btn:active{background:var(--panel2)}
.sku-clear-btn.with-scan{right:44px}
.sku-field input.dual-btn{padding-right:96px}
/* Full-viewport scanner. Fixed + max z-index so it covers the sticky .ptabs bar too — a half-covered
   tab bar under a live camera is a mis-tap waiting to happen.

   DELIBERATE LITERALS (#000, #fff, rgba(0,0,0,.45)) — .scan-* is on DesignTokenTests'
   LiteralExemptSelectors. The ground here is a LIVE CAMERA FEED, not a theme surface: the surround
   must be black so the frame reads as a viewfinder, the scrim is a neutral darkening of whatever the
   lens happens to see, and the hint must stay legible over an arbitrary carton. Theming these would
   hurt readability rather than help consistency. The one genuinely themed element — the scan-region
   border — does use var(--accent). */
.scan-overlay{position:fixed;inset:0;z-index:9000;background:#000;display:flex;align-items:center;justify-content:center}
.scan-video{width:100%;height:100%;object-fit:cover}
.scan-region{position:absolute;left:8%;right:8%;top:38%;height:24%;border:2px solid var(--accent);box-shadow:0 0 0 100vmax rgba(0,0,0,.45);pointer-events:none}
.scan-hint{position:absolute;top:calc(38% - 34px);left:0;right:0;text-align:center;font-family:var(--mono);font-size:13px;color:#fff;text-shadow:0 1px 3px #000;pointer-events:none}
.scan-cancel{position:absolute;bottom:max(24px,env(safe-area-inset-bottom));left:50%;transform:translateX(-50%);font-family:var(--mono);font-weight:700;letter-spacing:.8px;font-size:15px;color:var(--ink-dark);background:var(--bad);border:var(--border-w) solid var(--line);padding:13px 26px;box-shadow:3px 3px 0 var(--shadow);cursor:pointer;min-height:48px}
.bigbtn{display:block;width:100%;text-align:center;font-family:var(--mono);font-weight:700;letter-spacing:.8px;font-size:15px;color:var(--ink-dark);background:var(--good);border:var(--border-w) solid var(--line);padding:14px;box-shadow:3px 3px 0 var(--shadow);text-decoration:none;margin-top:6px;cursor:pointer}
.bigbtn:disabled{opacity:.5;cursor:not-allowed}
.bigbtn.adjust{background:var(--warn)} .bigbtn.count{background:var(--accent2)}
/* Quantity +/- steppers (2026-08-08 Receive mobile QoL, Receive + Adjust tabs — AdjustQty.Step).
   44px flanking buttons, same touch target as the rest of this screen; the input keeps text-align
   center so the digits read naturally between them. */
.qty-stepper{display:flex;gap:6px;align-items:stretch}
.qty-stepper input{flex:1 1 auto;min-width:0;text-align:center}
.stepbtn{flex:0 0 44px;width:44px;min-height:44px;display:flex;align-items:center;justify-content:center;font-family:var(--mono);font-weight:700;font-size:22px;line-height:1;background:var(--inset);border:var(--border-w) solid var(--line);color:var(--text);cursor:pointer}
.stepbtn:active{background:var(--panel2)}
/* Count tap-to-adopt chip (2026-08-08 Receive mobile QoL) — decided over auto-prefill, see
   Receive.razor's Count-tab remarks. align-items:stretch matches the chip's height to the input's
   (~44px), so it reads as one control without a fixed height duplicated here. */
.qty-with-chip{display:flex;gap:6px;align-items:stretch}
.qty-with-chip input{flex:1 1 auto;min-width:0}
.use-chip{flex:0 0 auto;display:flex;align-items:center;justify-content:center;padding:0 12px;font-family:var(--mono);font-size:12.5px;font-weight:700;white-space:nowrap;background:var(--inset);border:var(--border-w) solid var(--accent2);color:var(--accent2);cursor:pointer}
.use-chip:active{background:var(--panel2)}
/* Blocker 1 (2026-08-08 fix cycle): now that .chrome-suppressed lets this bar actually reach the
   viewport bottom (see app.css's @media(max-width:840px) rule) instead of stopping 100px short, it
   needs the same safe-area inset .tabbar already carries — otherwise its 44px targets land under an
   iPhone home indicator once there is no shell padding left to absorb it. */
.ptabs{display:flex;position:sticky;bottom:0;background:var(--panel);border-top:var(--border-w) solid var(--line);justify-content:space-around;padding:8px 2px calc(10px + env(safe-area-inset-bottom))}
.ptabs a{color:var(--dim);text-decoration:none;font-size:10px;font-family:var(--px);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;cursor:pointer;min-height:44px;min-width:44px}
.ptabs a.active{color:var(--accent)}
/* Touch-sized glyphs (2026-08-07 Receive polish) — 10px inherited text made these unreadable/untappable
   on a phone. 20px matches spec-ui-foundations.md §5a's own "mobile tab" icon precedent; 44px min tap
   target matches the same doc's "KPI corner" precedent. Sizing/spacing only, no layout restructure. */
.ptabs a span{font-size:20px;line-height:1}
.bigbtn .glyph{font-size:19px;vertical-align:-2px;margin-right:1px;display:inline-block}

/* campaign stats grid */
.meta{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px;margin-bottom:4px}
.meta .m .k{font-size:11.5px;color:var(--dim);text-transform:uppercase;letter-spacing:1px}
.meta .m .v{font-size:15px;margin-top:3px;color:var(--text-bright)}

/* barcode label preview
   DELIBERATE LITERALS (#000/#fff): this is a preview of a PRINTED label — physical black ink on
   physical white stock. It must stay black-on-white in every theme or the printed output stops
   matching the preview and the bars stop scanning. Only the surrounding frame is themed. */
.barcode{border:var(--border-w) solid var(--line);background:#fff;padding:8px 10px;margin-bottom:10px;text-align:center}
.barcode .bars{height:34px;background:repeating-linear-gradient(90deg,#000 0 2px,#fff 2px 4px,#000 4px 5px,#fff 5px 8px)}
.barcode .bcimg{line-height:0}
.barcode .bcimg svg{max-width:100%;height:auto}
.barcode .code{font-family:var(--mono);color:#000;font-size:11px;margin-top:3px}

/* ================= GearDex Ops: POs · Shipments · Forecast · Capital ================= */
/* (mockups NerdStack_Mock_POs.html + NerdStack_Mock_Forecast_Capital.html) */

/* inline edit inputs — PO/shipment line grids (finput) + forecast lever cells (tinput) */
.finput{font-family:var(--mono);font-size:14px;border:var(--border-w) solid var(--line);background:var(--inset);color:var(--text);padding:8px 10px;outline:none;width:100%}
.finput:focus{border-color:var(--accent)}
/* variant typeahead (shipment add-line pickers) — classic lookup with thumbnails */
.vpick{position:relative;display:inline-block}
.vpick-menu{position:absolute;top:100%;left:0;right:auto;min-width:480px;max-width:calc(100vw - 32px);z-index:50;background:var(--inset);border:var(--border-w) solid var(--accent);border-top:none;max-height:340px;overflow-y:auto;box-shadow:var(--shadow-offset) 0 var(--shadow)}
.vpick-head{display:flex;justify-content:space-between;align-items:center;padding:5px 10px;border-bottom:1px solid var(--line);background:var(--inset-2);font-size:10px;letter-spacing:1px;text-transform:uppercase;color:var(--dim);position:sticky;top:0}
.vpick-head b{color:var(--accent2);font-weight:700}
.vpick-item{display:flex;align-items:center;gap:10px;padding:6px 10px;cursor:pointer;border-bottom:1px solid var(--line)}
.vpick-item:last-child{border-bottom:none}
.vpick-item:hover,.vpick-item.active{background:var(--panel2)}
.vpick-item.active{box-shadow:inset 3px 0 0 var(--accent)}
.vpick-thumb{width:30px;height:30px;flex:0 0 30px;object-fit:cover;border:1px solid var(--line);background:var(--inset)}
.vpick-thumb.ph{display:grid;place-items:center;color:var(--dex-text);font-size:14px;background:var(--inset-3)}
.vpick-text{display:flex;flex-direction:column;gap:1px;min-width:0}
.vpick-sku{font-family:var(--mono);font-size:15px;color:var(--text);font-weight:600;white-space:nowrap}
.vpick-name{font-size:13px;color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.vpick-empty{padding:9px 10px;font-size:12px;color:var(--dim)}
.tinput{font-family:var(--mono);font-size:13px;border:var(--border-w) solid var(--line);background:var(--inset);color:var(--text);padding:3px 6px;width:70px;text-align:right;outline:none}
.tinput:focus{border-color:var(--accent)}

/* key/value grid (summary + details panels) */
.kv{display:grid;grid-template-columns:auto 1fr;gap:8px 16px;font-size:14px;align-items:center}
.kv .k{color:var(--dim)}

/* validation warn box */
.warnbox{border:var(--border-w) solid var(--warn);background:var(--warn-tint);padding:10px 12px;font-size:13px;margin-bottom:12px}

/* status timeline strip (PO / shipment detail) */
.timeline{display:flex;margin-bottom:14px;text-align:center}
.timeline .tl{flex:1;padding:10px 6px;border-right:var(--border-w) dashed var(--line)}
.timeline .tl:last-child{border-right:none}
.timeline .tl.on{background:var(--panel2)}
.timeline .tl.future{opacity:.5}
/* the actionable next-step cell (holds the workflow button, e.g. CONFIRM PO) is not dimmed */
.timeline .tl.future.next{opacity:1}
.timeline .tl .s{font-family:var(--px);font-size:9px}
.timeline .tl.done .s{color:var(--good)} .timeline .tl.on .s{color:var(--accent2)} .timeline .tl.future .s{color:var(--dim)}
.timeline .tl .d{font-size:12px;color:var(--dim);margin-top:5px}

/* section header with inline actions (■ TITLE ......... [btn][btn]) */
.cardhead{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:14px;gap:8px;flex-wrap:wrap}
.cardhead h3{margin-bottom:0}
.cardhead .acts{display:flex;gap:8px}

/* Dex explain sub-row (row-◆ EXPLAIN on the buy list) — pink left border, tinted */
.dexrow td{border-left:3px solid var(--dex);background:var(--dex-tint);font-size:13px;color:var(--dim);padding:10px 12px}
.dexrow .lead{color:var(--dex-text)}
.dex-glyph{color:var(--dex-text);text-decoration:none;font-size:12px}

/* forecast family/category drill-down rows */
tr.fam{background:var(--accent-zebra);cursor:pointer}
tr.fam td:first-child::before{content:"\25B8 ";color:var(--accent)}   /* ▸ */
tr.fam.open td:first-child::before{content:"\25BE ";color:var(--accent)} /* ▾ */
tr.cat td:first-child{padding-left:34px;color:var(--dim)}
tr.cat.clickable:hover{background:var(--panel2)}
.auto{font-size:10px;color:var(--dim)}   /* the read-only "auto" trend marker */

/* sparkline (the month position table drawn small) */
.spark{display:inline-flex;gap:2px;align-items:flex-end;height:22px}
.spark i{width:6px;background:var(--accent);display:block}
.spark i.neg{background:var(--bad)}

/* forecast-accuracy err cells */
.err-ok{color:var(--good)} .err-warn{color:var(--warn)} .err-bad{color:var(--bad)}

/* cash calendar segmented bar (committed PO / planned buy / forecast) */
.cashbar{height:16px;background:var(--inset);border:var(--border-w) solid var(--line);display:flex;margin-top:2px}
.seg-po{height:100%;background:repeating-linear-gradient(90deg,var(--bad) 0 6px,var(--stripe-bad-alt) 6px 8px)}
.seg-plan{height:100%;background:repeating-linear-gradient(90deg,var(--warn) 0 6px,var(--stripe-warn-alt) 6px 8px)}
.seg-fc{height:100%;background:repeating-linear-gradient(90deg,var(--accent) 0 6px,var(--stripe-accent) 6px 8px)}
.legend{display:flex;gap:16px;font-size:12.5px;color:var(--dim);margin-bottom:12px;flex-wrap:wrap}
.legend b{display:inline-block;width:12px;height:12px;vertical-align:-1px;margin-right:5px}
.cashmonth{margin-bottom:12px}
.cashmonth .row{display:flex;justify-content:space-between;font-size:13.5px;margin-bottom:4px;gap:10px;cursor:pointer}
.cashmonth .rows{width:auto;min-width:min(480px,100%);font-size:13px;margin:8px 0 4px 16px}

/* runchip variant used on ops pages (not right-pinned) */
.runchip.inline{margin-left:0}

@media (max-width:840px){
  .pdetail{grid-template-columns:1fr}
  /* S12 — the THIRD cause of the Overview's 390px overflow, and the same fact as `.quest .txt`
     wearing different clothes: a grid TRACK's implicit `min-width:auto` means the single column
     could not shrink below its content's min-content either. Stacking to 1fr was necessary and not
     sufficient. Overview's "WHAT TO BUY NOW" table has a SKU column carrying identifiers like
     NG-Playmat-Sanctuary-Purple, which are unbreakable words exactly like an exception string, so
     the track sat at 393px inside a 366px content box. min-width:0 lets the track shrink;
     .skucell breaking is what lets the table be that narrow without spilling in turn. Scoped to
     mobile deliberately — at 1280 these tables have room, and this slice repaints only what it
     must. */
  .pdetail > *{min-width:0}
  .skucell a,.skucell .sub{overflow-wrap:anywhere}
  .duo2{grid-template-columns:1fr}
  .timeline{flex-wrap:wrap}
  .timeline .tl{min-width:45%}
}

/* ================= Blazor Server reconnection UI ================= */
/* Hidden until blazor.web.js drops the circuit; -show = retrying, -failed/-rejected = gone.
   .nerd-circuit-stalled is ours, not Blazor's: the circuit never STARTED (see App.razor). */
#components-reconnect-modal{display:none}
#components-reconnect-modal.components-reconnect-show,
#components-reconnect-modal.components-reconnect-failed,
#components-reconnect-modal.components-reconnect-rejected,
#components-reconnect-modal.nerd-circuit-stalled{
  display:grid;place-items:center;position:fixed;inset:0;z-index:1000;padding:20px;
  background:var(--scrim-reconnect)}
.reconnect-box{width:min(430px,100%);padding:26px 28px;text-align:center;background:var(--panel)}
.reconnect-title{font-family:var(--px);font-size:13px;color:var(--accent);margin-bottom:12px}
.reconnect-box p{color:var(--dim);font-size:14px;margin-bottom:16px;line-height:1.5}
.reconnect-box p.fix{color:var(--text);font-size:13px}
.reconnect-retry,.reconnect-reload,.reconnect-stalled{display:none}
#components-reconnect-modal.components-reconnect-show .reconnect-retry{display:block}
#components-reconnect-modal.components-reconnect-failed .reconnect-reload,
#components-reconnect-modal.components-reconnect-rejected .reconnect-reload{display:block}
#components-reconnect-modal.nerd-circuit-stalled .reconnect-stalled{display:block}
/* THE ONE PLACE MUDBLAZOR OUT-SPECIFIES THE SHELL (S5 found it, S7 fixes it).
   MudBlazor.min.css ships `#components-reconnect-modal button{…}` — an ID selector, specificity
   (1,0,1) — which beats `.btn.primary` (0,2,0) NO MATTER what order the stylesheets load in. It
   sets color:var(--mud-palette-text-primary) plus padding/font-size/min-width/font-weight/
   line-height/border-radius/letter-spacing/text-transform/margin, so the RELOAD button takes Mud's
   ink on our `.btn.primary` purple ground and wears a half-Material shape.
   Invisible on retro, where every ink token is near-white anyway. On DexHQ --mud-palette-text-
   primary is --text #0E1116: near-black on #8D2FDA measures 3.24:1, under the 4.43:1 text floor —
   an unreadable button on the ONE screen a user sees when everything else has already failed.
   `#components-reconnect-modal .btn` is (1,1,0), which outranks (1,0,1) on the class count, so the
   shell wins WITHOUT !important. Every value below is `.btn`'s own — this restates, it does not
   design. A whole-shell sweep found no second case.
   NOT fixed here, and flagged in the PR instead: the same file's
   `#components-reconnect-modal{background-color:var(--mud-palette-background)!important}` also
   beats our --scrim-reconnect veil, so the overlay is OPAQUE rather than translucent in both
   themes. Beating an !important needs an !important, and it changes a shipped retro surface — a
   design call, not a defect fix. It is cosmetic: the box and its ink stay on-token either way. */
#components-reconnect-modal .btn{
  color:var(--ink-dark);font-weight:700;font-size:13px;letter-spacing:.8px;text-transform:uppercase;
  padding:8px 14px;line-height:1.2;border-radius:var(--radius);min-width:0;margin:0}
/* S12 DELETED: `#components-reconnect-modal .btn.primary{color:var(--ink-light)}` used to sit here
   to restore the light ink S7's ID rule above had overridden away on the RELOAD button. Now that
   .btn.primary itself paints --ink-dark, the ID rule already produces the right ink and the
   override would only reinstate the 3.57:1 pair this slice exists to remove. Nothing replaces it —
   RELOAD inherits --ink-dark on --accent, 5.21:1, exactly like every other primary button. */

/* ---- destructive action + confirm overlay (reference-data CRUD) ------------ */
/* Deliberately not a MudDialog: the shell is the retro console, and a Material
   dialog reads as a different app. Same panel/btn vocabulary as everything else. */
/* S12: same reasoning as .btn.primary, and it lands better. --ink-light on --bad was 3.02:1 — the
   confirm button on every destructive action in the app. --ink-dark on the same red is 6.16:1,
   which CLEARS the 5.27 house floor outright rather than merely clearing WCAG AA. Darkening --bad
   was rejected for the same reason as the severity fills: --bad is also the status INK (.hp.bad,
   .s-fail, .t-kill) on dark grounds, where it is already correct. */
.btn.danger{background:var(--bad);color:var(--ink-dark)}
.modalwrap{position:fixed;inset:0;z-index:200;background:var(--scrim-confirm);display:grid;place-items:center;padding:16px}
.modalwrap>.panel{width:100%;margin:0}

/* Outbound reference links in a list cell (supplier Alibaba/Website). The row itself is clickable,
   so these are the only anchors inside it — keep them visually distinct from the row's own hit area. */
td.links a{color:var(--accent2);text-decoration:none;margin-right:10px;white-space:nowrap}
td.links a:last-child{margin-right:0}
td.links a:hover{text-decoration:underline}
td.links span{color:var(--dim)}

/* ---- Transfers tables ------------------------------------------------------ */
/* "Why" rationale as a hover tooltip instead of a text column (Transfers > Region Moves). */
.whyicon{display:inline-grid;place-items:center;width:22px;height:22px;border:var(--border-w) solid var(--line);
  background:var(--panel2);color:var(--dim);font-family:var(--mono);font-size:12px;line-height:1;cursor:help}
.whyicon:hover{color:var(--accent2);border-color:var(--accent2)}

/* Region Moves: every column centres except the SKU. Scoped to this table on purpose —
   .reporttable is shared by 9 GearDex pages, so a bare .reporttable rule would restyle them all.
   Beats .r (0,1,1) on specificity (0,2,1), so leftover .r classes can't fight it. */
.rmoves th:not(:first-child),.rmoves td:not(:first-child){text-align:center}

/* Location Moves — same scoping rationale as .rmoves.
   Everything centres except SKU / Variant. Class-driven on purpose: rowspans mean the SKU cell is
   not at a fixed child index, so :nth-child() would centre it on every continuation row. */
.lmoves th,.lmoves td{text-align:center}
.lmoves th.skuh,.lmoves td.skucell{text-align:left}
/* Zebra alternates per LANE (From→To), not per row: the lane is the unit TRANSFER acts on, so the
   block should read as one thing. Applied to td (not tr) — a tr background sits behind cell
   backgrounds and rowspan'd group cells would punch through it. */
.lmoves tr.alt td{background:var(--accent-zebra)}
.lmoves .grpcell{vertical-align:middle;border-right:var(--border-w) solid var(--line);font-size:13px}
/* FROM spans exactly ONE lane (repeated per lane rather than centred across a tall source group),
   so it sits inside the band and takes the zebra like everything else. */
.lmoves .grpcell.fromcell{color:var(--text)}
/* A rule across the top of each lane's opening row makes the block boundary explicit even where two
   same-tone bands would otherwise meet. */
.lmoves tr.laneopen td{border-top:var(--border-w) solid var(--line)}

/* KPI corner sprite (GearDex Overview). The corner already belonged to the GO chevron, so the two
   share it: sprite at rest, GO → on hover. Scoped to .iconkpi — a.panel .go is also used on POs and
   Reports, where the chevron must stay put. */
.iconkpi .kpicon{position:absolute;top:10px;right:12px;width:44px;height:44px;opacity:.85}
.iconkpi:hover .kpicon{display:none}
.iconkpi .go{display:none}
.iconkpi:hover .go{display:block}

/* ============ MarketDex Performance (Slice 4) ============ */
/* Chart.js host — fills a fixed-height box (charts run maintainAspectRatio:false). */
.nerd-chart{position:relative;width:100%}
.nerd-chart canvas{width:100%!important}

/* Date/preset bar: preset chips + custom range inputs + compare toggle. */
.datebar{display:flex;gap:8px;align-items:center;margin-bottom:16px;flex-wrap:wrap}
.datebar input[type=date],.datebar input.dtin{background:var(--inset);border:var(--border-w) solid var(--line);color:var(--text);font-family:var(--mono);font-size:13px;padding:6px 9px;outline:none}
.datebar .lbl{font-size:12px;color:var(--dim);text-transform:uppercase;letter-spacing:1px}

/* Horizontal-bar breakdown (revenue by channel). */
.hrow{display:flex;align-items:center;gap:10px;font-size:13.5px;margin-bottom:9px}
.hrow .nm{width:118px;color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:none}
.hrow .hb{flex:1;height:14px;background:var(--inset);border:1px solid var(--line);min-width:0}
.hrow .hb div{height:100%}
.hrow .val{width:160px;text-align:right;white-space:nowrap;flex:none}

/* 1.15/0.85 split used by ROAS+funnel and pacing+attribution rows. */
.grid31{display:grid;grid-template-columns:1.15fr .85fr;gap:14px}

/* Pixel badge (NEW / partial markers). */
.badge{font-family:var(--px);font-size:8px;padding:4px 7px;display:inline-block}

@media (max-width:840px){.grid31{grid-template-columns:1fr}}

/* MarketDex CRUD forms (Events / Ad Budgets). */
.dtin{background:var(--inset);border:var(--border-w) solid var(--line);color:var(--text);font-family:var(--mono);font-size:13px;padding:6px 9px;outline:none;width:100%}
.evform{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px}
.evform label,.bform label{display:flex;flex-direction:column;gap:5px;font-size:12px;color:var(--dim);text-transform:uppercase;letter-spacing:.6px}
.evform label.wide{grid-column:1/-1}
.bform{display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap}
.bform label{min-width:130px}

/* ============ MarketDex LIVE (semi-live pulse) ============ */
/* Per-channel freshness chips — one per order-bearing feed. Green border = fresh, warn = stale
   (last success older than 2× the cadence). Mirrors the .freshness bar, one pill per feed. */
.updated{font-family:var(--mono);font-size:12px;color:var(--dim)}
.livechips{display:flex;flex-wrap:wrap;gap:10px;margin-bottom:16px}
.livechip{display:inline-flex;align-items:center;gap:8px;background:var(--panel);border:var(--border-w) solid var(--good);padding:7px 11px;font-size:13px}
.livechip.stale{border-color:var(--warn)}
.livechip .dot{width:9px;height:9px;background:var(--good);flex:none}
.livechip.stale .dot{background:var(--warn)}
.livechip .meta{color:var(--dim);font-size:12px}
.livechip .q{display:inline-flex;align-items:center;justify-content:center;width:15px;height:15px;border:1px solid var(--line);
  background:var(--panel2);color:var(--dim);font-family:var(--mono);font-size:11px;line-height:1;cursor:help;flex:none}
.livechip .q:hover{color:var(--accent2);border-color:var(--accent2)}

/* ---- WS-7 RoyalDex terms CRUD forms (adjustments · licensors · deals · designs · rates/rules) ----
   Reuses the shell's .field/.finput/.fsel/.kv/.grid2/.chip/.tag; adds the form grid, the basis
   toggle, and the inline rule banners. Auto-fit → single column at ≤380px (forms stack, spec §10). */
.fgrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(215px,1fr));gap:13px 16px}
.fgrid .field{margin-bottom:0;min-width:0}
.fgrid .span2{grid-column:span 2}
.fgrid .wide{grid-column:1/-1}
.field .req{color:var(--bad)}
/* Checkbox option rows (RoyalDex deal scope + deductions, 2026-08-17). Wraps rather than scrolls, so
   the five class ticks stay fully readable at 390px instead of clipping a money term off-screen.
   The explicit width:auto on the input overrides .field input's width:100% — without it every
   checkbox stretches to the full column. */
.checkrow{display:flex;flex-wrap:wrap;gap:8px 16px;align-items:center}
.checkopt{display:inline-flex;align-items:center;gap:7px;font-size:13.5px;color:var(--text);
  text-transform:none;letter-spacing:0;cursor:pointer;white-space:nowrap}
.checkopt input[type=checkbox]{width:auto;margin:0;flex:none;accent-color:var(--accent);cursor:pointer}
.checkopt input[type=checkbox]:disabled{cursor:not-allowed}
.checkopt:has(input:disabled){color:var(--dim);cursor:not-allowed}
.toggle{display:flex;gap:0}
.toggle button{font-family:var(--mono);font-weight:700;letter-spacing:.8px;text-transform:uppercase;font-size:12px;
  padding:7px 13px;cursor:pointer;border:var(--border-w) solid var(--line);background:var(--panel2);color:var(--dim)}
.toggle button.on{background:var(--accent);color:var(--ink-dark)}  /* S12 — see .btn.primary */
.toggle button:disabled{opacity:.5;cursor:not-allowed}
.banner{border:var(--border-w) solid var(--warn);background:var(--warn-tint);padding:10px 14px;font-size:13.5px;
  margin:12px 0;display:flex;gap:10px;align-items:baseline}
.banner.bad{border-color:var(--bad);background:var(--bad-tint-banner)}
.banner.info{border-color:var(--accent2);background:var(--accent2-tint)}
/* .ok (Admin Users & Roles success notices — "Saved X.", "Removed Y from Z."). No --good-tint wash
   exists (only accent/bad/warn/dex have a derived tint pair — see the app.css token-block header),
   and deriving one is a measured design-system change this cleanup doesn't need: --lift-band is the
   existing neutral "special row" wash, already gated in both themes, and paired with the --good
   border + --good check mark below it reads as success without inventing a new hue family. */
.banner.ok{border-color:var(--good);background:var(--lift-band)}
.banner .bang{font-family:var(--px);font-size:11px;color:var(--warn);flex:none}
.banner.bad .bang{color:var(--bad)}
.banner.info .bang{color:var(--accent2)}
.banner.ok .bang{color:var(--good)}
@media (max-width:840px){.fgrid .span2,.fgrid .wide{grid-column:auto}}
/* ===== OrderDex (hub / mto / shipments / exceptions) ===== */
tr.tap{cursor:pointer}
tr.tap:hover{background:var(--accent-hover)}
.pager{display:flex;align-items:center;gap:14px;margin-top:14px;justify-content:center}
.pager .sub{color:var(--dim);font-size:13px}
/* mto board: design-grouped grid of job cards, mobile-first (single column at 380px) */
.mtogroup{margin-bottom:22px}
.mtogroup h2{font-family:var(--px);font-size:11.5px;color:var(--accent2);margin:0 0 10px;display:flex;align-items:center;gap:10px}
.jobgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:10px}
@media(max-width:380px){.jobgrid{grid-template-columns:1fr}}
.jobcard{background:var(--panel2);border:var(--border-w) solid var(--line);box-shadow:3px 3px 0 var(--shadow);padding:12px 13px}
.jobcard.sel{outline:2px solid var(--accent);background:var(--accent-sel)}
.jobcard .thumb{width:44px;height:44px;border:var(--border-w) solid var(--line);background:var(--panel);object-fit:cover;flex:none}
.jobcard .jtop{display:flex;gap:10px;align-items:flex-start}
.jobcard .adv{width:100%;margin-top:10px}
.timeline{list-style:none;margin:0;padding:0}
.timeline li{position:relative;padding:6px 0 6px 20px;border-left:var(--border-w) solid var(--line);margin-left:5px}
.timeline li .mk{position:absolute;left:-6px;top:9px;width:9px;height:9px;background:var(--accent2)}
.timeline li .tt{color:var(--dim);font-size:12px}
.ratecard{display:flex;justify-content:space-between;align-items:center;border:var(--border-w) solid var(--line);background:var(--panel2);padding:9px 12px;margin-bottom:8px;cursor:pointer}
.ratecard.sel{outline:2px solid var(--accent);border-color:var(--accent);background:var(--accent-sel)}
/* OrderDex detail/shipments/settings tables have NO card variant — instead of the global
   reporttable→cardlist hide at ≤700px (which would blank them), keep them and let the table itself
   scroll horizontally inside its box (no page-level horizontal scroll at 380px). Two-class specificity
   beats the global .reporttable{display:none}. */
@media(max-width:700px){ .reporttable.odx{display:block;overflow-x:auto;white-space:nowrap} }

/* ---- OrderDex fulfillment groups (order detail) ----
   One card per fulfillment location. The group is DERIVED from order_lines.fulfillment_location_code —
   there is no group entity, so there is nothing here that encodes a specific location. */
.odx-group{margin-bottom:14px}
.odx-group-head{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap;margin-bottom:10px;
  padding-bottom:8px;border-bottom:var(--border-w) solid var(--line)}
.odx-group-head .sub{color:var(--dim)}
.odx-group-head .grow{margin-left:auto;display:flex;gap:6px;align-items:center;flex-wrap:wrap}
/* The SKU cell must not be squeezed by the two columns this build adds (the multi-select checkbox and
   the MOVE TO picker). Without a floor, the auto table layout hands the leftovers to those two and the
   SKU wraps MID-TOKEN ("VIS-CMP-\n01") while a locked line's reason renders as a ~70px vertical column
   ~250px tall. Both are legible-but-wrong, and neither shows up in a unit test. */
.odx-group td.skucell{min-width:180px}
.odx-group td.skucell .sub{max-width:320px;white-space:normal}
/* The select column takes what its control needs and no more. */
.odx-group .reporttable td:last-child select{max-width:130px}
/* Item 7 (2026-08-09): the per-line MOVE TO trigger shrank from a "MOVE TO →" text label to a compact
   icon + arrow — the column narrows with it. The group-level CHANGE LOCATION picker is unaffected (its
   own select, not .odx-move-compact). */
.odx-move-compact{max-width:64px}
/* Scroll the group table INSIDE its card at EVERY width, not just on a phone. A group table carries
   two more columns than the old flat line table (the multi-select box and the MOVE TO picker), so on a
   narrow desktop track it can still out-measure its card — and a clipped <select> is an action the
   operator simply cannot reach. Scrolling in place is the honest failure mode; clipping is not. */
.odx-group .reporttable{display:block;overflow-x:auto;max-width:100%}
.odx-group .reporttable th:first-child,
.odx-group .reporttable td:first-child{width:1%;white-space:nowrap}
/* Keep the group action on the header's right on wide screens instead of letting it wrap for one group
   and not the next purely because their names differ in length. */
.odx-group-head .grow{flex:0 0 auto}

/* Drag affordances. Drag is an ENHANCEMENT — every move it can do is also on the row's MOVE TO menu,
   so a browser or input device that cannot drag loses nothing. */
.odx-line-drag{cursor:grab}
.odx-line-drag:active{cursor:grabbing}
.odx-line-locked{opacity:.62}
.odx-group.odx-drop-hot{outline:2px dashed var(--accent);outline-offset:3px}
.odx-group.odx-drop-blocked{outline:2px dashed var(--bad);outline-offset:3px}
/* The order-detail two-column layout (lines/groups on the left, SHIP TO + ACTIONS on the right). */
/* The side column holds SHIP TO + ACTIONS — content of a KNOWN, modest width. Giving it a fixed track
   instead of 1fr hands the surplus to the group tables, which are the ones that actually need it. */
.odx-detail-grid{display:grid;grid-template-columns:minmax(0,1fr) 300px;gap:16px;align-items:start}
/* A grid item defaults to min-width:auto, i.e. "never shrink below my content". With a wide group
   table inside, that floor beats the track sizing and pushes the PAGE wide — and it also stops the
   table's own overflow-x:auto from ever engaging, because the item just grows instead. min-width:0 is
   what lets the table scroll inside its card rather than the document scroll around it. */
.odx-detail-grid > *{min-width:0}

@media(max-width:700px){
  .odx-group-head .grow{margin-left:0;width:100%;flex:1 1 100%}
  /* ONE column on a phone. Two 'fr' tracks do not shrink below their content's minimum, so beside a
     group table with any floor at all they push the PAGE wide instead of wrapping. */
  .odx-detail-grid{grid-template-columns:1fr}
  /* …and drop the SKU floor here: .reporttable.odx already scrolls the TABLE in place at this width,
     which is the behaviour we want. Keeping the floor would scroll the whole page instead. */
  .odx-group td.skucell{min-width:0}
  .odx-group td.skucell .sub{max-width:none}
}

/* Channel Display Identity Cut (DECISIONS 2026-08-09): ChannelBadge — glyph + accent + display name.
   The glyph's fill comes from row DATA (accent_color), not a theme token, so it is set inline rather
   than through a CSS variable; sizing is the only thing that belongs here (spec §5's "size is not a
   set property" rule, applied to this small non-theme-resolved glyph set too). */
.channel-badge{display:inline-flex;align-items:center;gap:6px}
.channel-badge-icon{flex:none;shape-rendering:crispEdges}
.channel-badge-list .channel-badge-icon{width:14px;height:14px}
.channel-badge-list .channel-badge-label{font-size:13px}
.channel-badge-detail .channel-badge-icon{width:18px;height:18px}
.channel-badge-detail .channel-badge-label{font-size:14.5px;color:var(--dim)}

/* Ship panel (/orders/ship/{channel}/{order}). Same two-column shape as .odx-detail-grid and the same two
   protections, for the same reasons: minmax(0,…) + min-width:0 stop a grid item's content floor from
   pushing the PAGE wide, and the phone breakpoint drops to ONE column. Without these the rates and manual
   cards sat in a second track that ran off a 390px viewport entirely. */
.odx-ship-grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.3fr);gap:16px;align-items:start}
.odx-ship-grid > *{min-width:0}

@media(max-width:700px){
  .odx-ship-grid{grid-template-columns:1fr}
}

/* ================= DEX✦ INSIGHTS (spec §9) — namespaced dex- to avoid the global .chip/.meta ================= */
.dex-insight-card{padding:24px 26px}
.dex-ihead{display:flex;justify-content:space-between;align-items:flex-start;gap:18px;flex-wrap:wrap;
  margin-bottom:22px;padding-bottom:16px;border-bottom:var(--border-w) solid var(--line)}
.dex-ihead .left{display:flex;align-items:baseline;gap:11px}
.dex-ihead .tick{color:var(--dex-text);font-family:var(--px);font-size:13px}
.dex-period{font-family:var(--px);font-size:12px;color:var(--text-bright);letter-spacing:.5px}
.dex-range{color:var(--dim);font-size:13.5px;margin-top:9px}
.dex-meta{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
.dex-chip{font-size:11.5px;color:var(--dim);border:var(--border-w) solid var(--line);background:var(--panel2);
  padding:4px 9px;letter-spacing:.4px;white-space:nowrap}
.dex-chip.on{color:var(--dex-text);border-color:var(--dex-line)}

.dex-insight-body{max-width:70ch}
.dex-insight-body h3{font-family:var(--px);font-size:10.5px;letter-spacing:.5px;color:var(--accent2);
  text-transform:uppercase;margin:26px 0 12px;padding-top:19px;border-top:var(--border-w) dashed var(--line);
  display:flex;align-items:center;gap:9px}
.dex-insight-body h3::before{content:"\2726";color:var(--dex-text)}
.dex-insight-body h3:first-child{margin-top:0;padding-top:0;border-top:0}
.dex-insight-body p{margin:0 0 11px;font-size:14.5px;line-height:1.7;color:var(--text)}
.dex-insight-body p:last-child{margin-bottom:0}
.dex-insight-body h3:first-child + p{font-size:16.5px;line-height:1.55;color:var(--text-bright)}
.dex-insight-body strong{font-weight:600;color:var(--text-bright)}
.dex-insight-body ul{list-style:none;padding:0;margin:4px 0 0;display:flex;flex-direction:column;gap:10px}
.dex-insight-body li{position:relative;padding-left:22px;font-size:14.5px;line-height:1.55}
.dex-insight-body li::before{content:"\25B8";color:var(--dex-text);font-family:var(--px);font-size:10px;position:absolute;left:0;top:2px}

.dex-ifoot{margin-top:22px;padding-top:14px;border-top:var(--border-w) dashed var(--line);
  color:var(--dim);font-size:12.5px;display:flex;gap:9px;flex-wrap:wrap;align-items:center}
.dex-ifoot .fdex{color:var(--dex-text)}
@media (max-width:840px){ .dex-insight-card{padding:18px 15px} }

/* ================= Admin — Users & Roles (2026-08-05 cleanup) =================
   The PEOPLE/ROLES tables were rendering with class="grid" — a class app.css never defined, so the
   browser's zero-padding table default was the entire visual design. Miguel's "near-zero padding,
   cramped" read was correct: switching to plain <table> alone recovers the shell's real table rules
   (th/td padding, .status/.tag ink, tr.muted). Everything below is what plain <table> cannot give it
   for free: the role editor as a modal instead of an appended section, grouped/highlighted checkboxes,
   and the chip/action treatments the build prompt asked for. All values are existing tokens — no new
   custom property is declared here (see the app.css token-block header on why that matters). */

/* deactivated user row / shelved role row — same ink-only dimming as every other muted row (S5c),
   just spelled out for the two tables that still wrote "muted" against an undefined class. */
tr.muted td{color:var(--dim)}

.rowactions{display:flex;gap:8px;align-items:center;justify-content:flex-end;flex-wrap:wrap}

.roleident{display:flex;align-items:center;gap:8px}
/* the role description used to sit on the same text node as the name/built-in tag with only a
   Razor-source newline between them — a space in the DOM, but no visual break, which is why it read
   as run-together prose. Block-level and on its own line, same fix shape as .setrow-implies below. */
.rowdesc{display:block;margin-top:4px}

/* role chips on a person's row, with an inline × remove. .tag already gives the pixel-font chip
   shape; this only adds the flex layout the embedded button needs to sit on one baseline with it. */
.roletags{display:flex;flex-wrap:wrap;gap:6px;align-items:center}
.tag.rolechip{display:inline-flex;align-items:center;gap:6px}
/* an unstyled <button> inside a .tag inherits browser button chrome (padding, border, background) —
   this resets it to a bare glyph so "×" reads as part of the chip, not as a second control glued to
   it. Ink dims to --bad on hover/focus as the only cue this one is destructive; there is no separate
   confirm step, so the colour change is the whole warning. */
.linkbtn{background:none;border:0;padding:0;margin:0;font:inherit;line-height:1;cursor:pointer;color:var(--dim)}
.linkbtn:hover,.linkbtn:focus-visible{color:var(--bad)}

/* the role editor modal — grouped, money-distinguished checkboxes (Roles_Permissions_Feature_Outline
   .md "The permission sets": Cross-cutting / GearDex / OrderDex / Other modules & platform). */
.checkline{display:flex;align-items:center;gap:8px;font-size:14px;margin:14px 0}
.worksets{margin-top:16px}
.worksets-head{font-size:12.5px;color:var(--dim);text-transform:uppercase;letter-spacing:1px;margin-bottom:10px}
.setgroup{margin-bottom:16px}
.setgroup:last-child{margin-bottom:0}
.setgroup-head{font-family:var(--px);font-size:9.5px;color:var(--accent2);letter-spacing:.6px;
  text-transform:uppercase;padding-bottom:6px;margin-bottom:8px;border-bottom:var(--border-w) solid var(--hairline)}
.setrow{display:flex;align-items:flex-start;gap:10px;padding:7px 8px;cursor:pointer}
.setrow:hover{background:var(--panel2)}
.setrow input[type=checkbox]{margin-top:3px;flex:none}
.setrow-body{display:flex;flex-direction:column}
.setrow-name{font-size:14px}
/* the concatenation bug: "also grants …" used to be a same-line <span> right after the label text,
   separated by nothing but a source-code newline. Its own block line with top margin guarantees the
   separation regardless of how Blazor collapses inline whitespace — see the razor comment. */
.setrow-implies{display:block;font-size:12px;color:var(--dim);margin-top:2px}
/* the money switches — "the most important toggles on the page" per the build prompt. Same
   border+tint pairing .warnbox already uses for "handle with care", reused rather than inventing a
   third emphasis colour: this page already has --warn meaning caution, and money is exactly that. */
.setrow.money{border-left:3px solid var(--warn);background:var(--warn-tint);margin-left:-3px}
.moneymark{font-family:var(--px);font-size:9px;color:var(--warn);border:1px solid var(--warn);
  padding:1px 4px;margin-left:6px;line-height:1.4}

/* the fails-closed landing (/no-access) — trivially adjacent per the build prompt: same "a bare
   .panel with no page-specific rule" gap, same fix shape (padding + readable measure + spaced text). */
.no-access{padding:36px 32px;max-width:560px;margin:40px auto}
.no-access h1{font-family:var(--px);font-size:16px;color:var(--text-bright);margin-bottom:16px}
.no-access p{font-size:15px;line-height:1.6;margin-bottom:12px}
.no-access .lead{color:var(--dim)}
.no-access .dex-note{display:flex;align-items:center;gap:8px;margin-top:20px;padding-top:16px;
  border-top:var(--border-w) dashed var(--line);font-size:13px;color:var(--dim)}

/* ===== Operations Schedule Console (/admin/operations, Setup track S3) =====
   Two rules, both scoped to this page's own classes so no existing surface moves a pixel.

   .opsnote exists because there is NO bare `.sub` rule — `.sub` is only ever defined scoped
   (.pagehead .sub, .skucell .sub, .pager .sub, .rcard .top .sub). Two shipped pages already write an
   unscoped `.sub` for a panel caption and get UNSTYLED text for it, silently, because an undefined
   class is invisible to the compiler and to every gate. Adding a bare `.sub` here would restyle those
   two pages as a side effect of this build; a page-scoped name does the job and changes nothing else.

   The .skucell width cap mirrors `.odx-group td.skucell .sub` exactly: `.skucell .sub` is display:block
   with no measure, so a two-sentence job description would stretch the Operation column across the
   viewport. Colours are token references only — nothing is declared outside :root. */
.opsnote{display:block;color:var(--dim);font-size:13.5px}
.opstable td.skucell .sub{white-space:normal;max-width:420px}

/* ===== Connections console (/admin/connections, W-CRED) =====
   One rule, a page-scoped NAME rather than a bare `.sub`, for exactly the reason the .opsnote block
   above records: `.sub` is only ever declared scoped (.pagehead .sub, .skucell .sub, .pager .sub,
   .rcard .top .sub) and NONE of those matches `.rcard .metrics .sub`, so the W-CRED source badge
   ("· STORE", "· .env") shipped unstyled — inheriting `.rcard .metrics .lbl{color:var(--dim)}` at full
   size, i.e. typographically identical to the credential key it annotates. Review N1, 2026-08-26.

   Not `.rcard .metrics .sub`: `.rcard` is shared by ~20 GearDex/OrderDex pages, so that selector is a
   shared-surface change wearing a page-scoped costume. This name is reachable only from the markup
   that opts into it, which is what makes "no existing surface moves a pixel" true rather than hoped.

   12px against the container's 14px is the same step `.rcard .top .sub` takes, so the badge reads as
   an annotation on the line above it. Colour is a token reference; nothing is declared outside :root. */
.connsub{font-size:12px;color:var(--dim)}

/* ================= ADMIN STORES + LOCATIONS (operator-first rework, 2026-08-27) =================
   Page-scoped names, per the .connsub / .opsnote precedent: every one of these is reachable only
   from the two admin pages that opt into it, which is what makes "no existing surface moves a pixel"
   true rather than hoped.

   WHY THIS BLOCK EXISTS AT ALL. Both pages shipped writing `class="dim"` (25 times) and
   `class="grid"` (3 times) — and app.css declares NEITHER. Every one of those elements rendered as
   full-size, full-brightness body text and every table rendered as bare UA HTML, which is a large
   part of why the pages read as an undifferentiated dump. AdminPagesCssClassTests now walks every
   class attribute on both files and fails on any name app.css does not declare, so that specific
   failure cannot come back silently. (The `.grid` in GearDex/Channels.razor is the same defect on a
   module page and is NOT touched here — that page is its module builder's surface.) */

/* The one quiet line that replaced the two-key banner, and the read-only notice. */
.admlede{font-size:13px;color:var(--dim);margin:0 0 14px}

/* A store's identity line, under the card head. */
.admsub{font-size:12.5px;color:var(--dim);margin-bottom:4px}

/* GLANCE TWO: the capability badges. A row of triggers, not a row of buttons — the whole point of
   the rework is that a healthy store is read, not operated. */
.admcaps{display:flex;flex-wrap:wrap;align-items:center;gap:8px 18px;margin-top:10px}
.admcap{display:inline-flex;align-items:center;gap:7px;background:none;border:0;padding:0;margin:0;
  font:inherit;color:var(--text);cursor:pointer;text-align:left}
.admcap .k{font-size:13px;color:var(--dim)}
.admcap:hover .k,.admcap:focus-visible .k{color:var(--text)}
.admcaret{font-size:11px;color:var(--dim)}
/* A capability this store's kind cannot do: ONE muted line, and no control that could never work. */
.admcap-na{font-size:13px;color:var(--dim)}

/* GLANCE THREE: the panel you asked for. --inset ground, whose --text and --dim pairs the contrast
   gate already measures (.field input / .runlog). */
.admpanel{border:var(--border-w) solid var(--line);background:var(--inset);padding:12px 14px;
  margin-top:12px}
.admpanel-head{font-family:var(--px);font-size:9.5px;color:var(--accent2);letter-spacing:.6px;
  margin-bottom:9px}
.admnote{display:block;font-size:13px;color:var(--dim);margin:0 0 8px;max-width:74ch}
.admact{margin-top:10px}
.admcheck{display:flex;align-items:flex-start;gap:8px;font-size:13.5px;margin-bottom:10px;cursor:pointer}
.admcheck input{margin-top:3px;flex:none}
/* Standing down is always legal and is never the thing being decided, so it is a quiet text action
   rather than a second button competing with the one transition button. */
.admstand{margin-top:6px;font-size:12.5px}
.admhist{margin-top:12px;font-size:12px;color:var(--dim);line-height:1.7}

/* Secondary text inside a table cell. Not `.sub`: all four shipped `.sub` rules are scoped to other
   containers, so a bare `.sub` here would resolve to nothing — the exact trap .connsub records. */
.admrowsub{display:block;font-size:12px;color:var(--dim);margin-top:2px}
.admrowbad{color:var(--warn)}

/* THE MIGRATION WALK. A numbered list where a step is only reachable once the one before it is
   ticked — the procedure that used to be six paragraphs of prose sitting on the page permanently. */
.admsteps{margin:0;padding-left:22px}
.admstep{margin-bottom:12px}
.admstep-title{font-size:14px}
/* A step you have not reached yet is dimmed by INK, not by opacity: the contrast gate measures token
   pairs and is blind to an ancestor's opacity, so `opacity:.45` would be an unmeasured legibility
   change wearing a styling change's clothes. --dim on --inset is a pair the gate already measures. */
.admstep-locked .admstep-title{color:var(--dim)}

/* ADMIN TABLES SURVIVE A PHONE. The global rule hides .reporttable below 700px and shows a .cardlist
   understudy instead; these tables have no card variant, so without this they would render as EMPTY
   panels at 390px — the same blank-page failure the Connections page's header comment records from
   the other direction. Same escape hatch, same reason, as `.reporttable.odx` above: keep the table
   and let it scroll horizontally inside its own box, so nothing is unreachable and the page itself
   never scrolls sideways. Two-class specificity beats the global .reporttable{display:none}. */
@media(max-width:700px){ .reporttable.adm{display:block;overflow-x:auto;white-space:nowrap} }

/* ─── SIGN-IN (W4b) ────────────────────────────────────────────────────────────────────────────
   The magic-link sign-in surface is a set of minimal-API HTML endpoints, not Razor components: a
   cookie can only be written while the response is open, which an interactive circuit's is not. So
   these two classes are the ONLY chrome those pages get — no shell, no nav, no per-user state, all
   of which need a principal that by definition does not exist yet.

   Declared here rather than inline in the endpoint's HTML because :root in this file is the single
   place a colour may be declared (the design-token contract), and a sign-in page that carried its
   own hex values would be the one surface in the app that did not follow the theme. Both classes are
   asserted present by SignInPageTests — an undeclared class renders as unstyled text that still says
   the right words, which on the sign-in page reads as a broken deploy. */
.signin-body{background:var(--bg);color:var(--text);font-family:var(--mono);margin:0;
  min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}
/* min-width:0 so a long email address wraps instead of widening the card past a 390px viewport —
   the same rule every other narrow surface in this file needs, for the same reason. */
.signin-card{width:100%;max-width:420px;min-width:0;overflow-wrap:anywhere}
.signin-card h1{font-size:20px;margin:0 0 6px}
.signin-card form{margin-top:14px}
/* An EMPTY required field is :invalid from first paint, so app.css's `.field input:invalid` rule
   (written for a field somebody has typed in) greeted every visitor with a red error box around an
   untouched email input — caught by the sign-in capture, which is the entire reason a new page gets
   photographed. Page-scoped rather than fixed at `.field input:invalid`, because that rule is shell
   surface and ~20 other forms rely on it as written.
   :placeholder-shown (with placeholder=" " on the input) is the "still empty" test, so the field
   reads normal until you type something that is actually wrong — which is when red means something. */
.signin-card .field input:invalid:placeholder-shown{border-color:var(--line)}

/* ── Settings → Notifications panel (W-NOTIFY, 2026-08-29) ────────────────────────────────
   Every class this panel introduces is declared here and PAGE-SCOPED under .notifyroutes,
   per the .opsnote / .connsub precedent: a bare `.nrow` or (worse) a `.sub` inside a card
   would either resolve to nothing or reach the ~20 pages that already use .rcard. The
   NotificationsPanelCssTests gate reads the component's class attributes and checks each
   one against this file, because an undeclared class renders as unstyled text that still
   says the right words — invisible to the compiler, the tests and a same-theme screenshot.

   No NEW colour token: --dim on card ground is the pairing .rcard .top .sub already uses,
   so the contrast gate needs no edit. */
.notifyintro{color:var(--dim);font-size:14px;margin:0 0 12px}
.notifyroutes{display:flex;flex-direction:column;gap:14px}
.notifyroutes .nrow{border-top:1px solid var(--line);padding-top:12px}
.notifyroutes .nrow:first-child{border-top:0;padding-top:0}
.notifyroutes .nlabel{color:var(--text-bright);font-size:14.5px}
.notifyroutes .ndest{color:var(--dim);font-size:13px;margin-top:2px;overflow-wrap:anywhere}
.notifyroutes .nwebhook{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:8px}
.notifyroutes .nwebhook input{flex:1 1 260px;min-width:0}
.notifyroutes .nhint{color:var(--dim);font-size:12px;flex:1 1 100%}
.notifyroutes .nactions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:8px}
.notifyroutes .nresult{color:var(--dim);font-size:12.5px;overflow-wrap:anywhere;min-width:0}
