/* Wash360 brand layer over AdminLTE 4 / Bootstrap 5 — BRAND v2 (D-062).
   Plain CSS custom-property overrides — no Sass, no build (D-016).
   Loaded LAST so it wins the cascade.

   The token system is `--w-*` (BRAND.md "App tokens"), declared twice: once
   for light (`:root, [data-bs-theme=light]`) and once for dark. Everything
   else in this file — and every page module — reads the tokens, never a hex.
   The v1 `--w360-*` names are KEPT as aliases onto the v2 tokens: 16 page
   modules and the Chart.js getComputedStyle reads depend on those names, so
   they are never removed, only re-pointed.

   Component law (BRAND.md): ZERO border radius; 2px rules between regions,
   1px between rows/cards; NO shadows — hover moves the border color, not
   elevation; focus is a 2px azure ring at 2px offset; disabled is 45%. */

/* ------------------------------------------------------------------ TOKENS */

:root,
[data-bs-theme=light] {
  /* Brand v2 app tokens (BRAND.md, D-062) */
  --w-bg: #EDF1F5; --w-side: #FFFFFF; --w-card: #FFFFFF; --w-head: #FFFFFF;
  --w-rule: rgba(4,30,66,.13); --w-rule-hard: rgba(4,30,66,.30);
  --w-text: #041E42; --w-dim: rgba(4,30,66,.60); --w-faint: rgba(4,30,66,.42);
  --w-hover: rgba(4,30,66,.045); --w-tile: #F7F9FB;
  --w-accent: #0054FF; --w-accent-ink: #003BB3; --w-accent-tint: #E4ECFF;
  --w-glow: rgba(0,84,255,.08);
  --w-signal-red: #C8102E; --w-signal-green: #3AA346; --w-foam: #DCE4EC;
  /* Legacy v1 names (bare-imported by version-pinned pages + Chart.js
     getComputedStyle reads) — ALIASED to v2, never removed: */
  --w360-accent: var(--w-accent);
  --w360-text: var(--w-text);
  --w360-hover: var(--w-accent-ink);
  --w360-active: var(--w-accent-ink);
  --w360-disabled: rgba(0,84,255,.45);
  --w360-d1: var(--w-accent-tint); --w360-d2: var(--w-accent); --w360-d3: var(--w-accent);
  /* Chart slots UNCHANGED this plan (CW-16 validation; Plan 3 re-bases): */
  --w360-chart-1: #0284c7; --w360-chart-2: #d97706; --w360-chart-3: #7c3aed;
  /* CW-56 (Task 12, CRM Performance trend band — 5 channels): two more
     categorical slots, dataviz-skill validated (scripts/validate_palette.js,
     order blue/amber/violet/teal/red — ALL CHECKS PASS on both this card
     surface (#FFFFFF) and the dark card surface (#0D1826); mode-invariant
     hex, same precedent as --w-signal-red/--w-signal-green above). Fixed
     channel->slot assignment (never cycled): marketing=1, recovery=2,
     saves=3, online_sales=4, self_serve=5. */
  --w360-chart-4: #0d9488; --w360-chart-5: #dc2626;
  /* CW-56 Tier 3 (family x site effectiveness heatmap — RETIRED for this grid by the r3
     redesign below; kept declared, never deleted, in case a future magnitude-only grid wants
     this exact validated ramp): a 5-step SEQUENTIAL ramp — ONE hue (the SAME #0284c7 chart-1
     blue the trend band's marketing bucket already uses), light->dark = low->high magnitude.
     dataviz-skill scripts/validate_palette.js --ordinal: ALL CHECKS PASS against this card's
     light surface (#FFFFFF) — monotone lightness, adjacent-step gaps, single-hue spread, and
     the palest step still clearing the surface at >=2:1. The dark block below carries this
     ramp's OWN independently-validated steps — never an automatic flip. */
  --w360-heat-1: #9cb9d0; --w360-heat-2: #6a98ba; --w360-heat-3: #3577a4;
  --w360-heat-4: #00568e; --w360-heat-5: #003377;
  /* CW-56 r3 (Chris ruled 2026-08-15 — round 3: "re-color green to red for what is working"): the
     campaign-group x site heatmap's 7-step ROW-RELATIVE DIVERGING scale — 3 red-orange steps
     (below THAT ROW's own average), a neutral midpoint (AT the average, --bs-tertiary-bg — the
     SAME neutral wash .w360-heat-low-n already uses, so "at average" and "low confidence" read
     as the same "nothing to see here" surface), 3 emerald steps (above average). Red-orange/
     emerald picked over pure red/green specifically for CVD separation (dataviz-skill
     scripts/validate_palette.js) — full validation report in the CW-56 r3 delivery report.
     Each arm independently validated as its OWN --ordinal ramp against THIS surface (#FFFFFF):
     ALL CHECKS PASS both arms (monotone L, one hue, light end clears surface >=2:1). The two
     steps flanking neutral (mildest red vs mildest green — the pair most at risk of red/green
     confusion) checked as an adjacent categorical pair: CVD ΔE 7.7 (protan) — the 6-8 floor
     band, legal ONLY with mandatory secondary encoding, which this grid already carries (every
     cell's value is real text, never color alone); normal-vision ΔE 25.5, clear of the 15 floor.
     Mode-invariant hex per chromatic step (the --w-signal-red/-green precedent) — only WHICH
     bucket gets which hex differs per theme (see the dark block below): light reads light->dark
     for rising severity (the surface is white); dark flips dark->light (the surface is black) —
     the SAME direction reversal --w360-heat-1..5 above already carries between its own
     light/dark blocks. Bucket index 1..7 maps 1:1 to heatBucketIndex()'s 0..6
     (crm-performance.js) via `--w360-diverge-(idx+1)`. */
  --w360-diverge-1: #9a3412; --w360-diverge-2: #ea580c; --w360-diverge-3: #fb923c;
  --w360-diverge-4: var(--bs-tertiary-bg);
  --w360-diverge-5: #10b981; --w360-diverge-6: #059669; --w360-diverge-7: #065f46;
  /* The two ink tokens a heat cell's VALUE text picks between (both the retired sequential
     ramp AND the r3 diverging ramp share these — same OKLCH-threshold mechanism,
     crm-performance.js's heatInkForStep()). Mode-INVARIANT literals (same precedent as
     --w-signal-red/--w-signal-green) — which one a given cell uses depends on THAT cell's own
     ramp-step lightness (computed, never eyeballed), not on the page's light/dark theme. */
  --w360-heat-ink-dark: #041E42; --w360-heat-ink-light: #E9EFF6;
  /* Bootstrap/AdminLTE re-basing. The *-rgb / surface entries are NOT
     cosmetic: Bootstrap resolves --bs-card-bg, --bs-table-bg, --bs-dropdown-bg
     and --bs-modal-bg from --bs-body-bg, so re-pointing body-bg alone would
     paint every card, table and menu in the PAGE GROUND color. The rgb
     triplets must be literals — CSS cannot feed a var() into rgba()'s
     comma-separated slot form. */
  --bs-body-bg: var(--w-bg);
  --bs-body-bg-rgb: 237,241,245;
  --bs-body-color: var(--w-text);
  --bs-body-color-rgb: 4,30,66;
  --bs-emphasis-color: var(--w-text);
  --bs-emphasis-color-rgb: 4,30,66;
  --bs-secondary-color: var(--w-dim);
  --bs-secondary-bg: var(--w-tile);
  --bs-secondary-bg-rgb: 247,249,251;
  --bs-tertiary-bg: var(--w-tile);
  --bs-tertiary-bg-rgb: 247,249,251;
  --bs-border-color: var(--w-rule);
  --bs-border-color-translucent: var(--w-rule);
  --bs-primary: var(--w-accent);
  --bs-primary-rgb: 0,84,255;
  /* BOTH forms are needed. `a` reads --bs-link-color-rgb, but .pagination and
     .btn-link read --bs-link-color — re-basing only the rgb triplet leaves
     those two components on Bootstrap's #0d6efd. */
  --bs-link-color: var(--w-accent);
  --bs-link-hover-color: var(--w-accent-ink);
  --bs-link-color-rgb: 0,84,255;
  --bs-link-hover-color-rgb: 0,59,179;
  --bs-focus-ring-color: var(--w-glow);
  --bs-body-font-family: 'Archivo', system-ui, sans-serif;
  --bs-border-radius: 0; --bs-border-radius-sm: 0; --bs-border-radius-lg: 0;
  --bs-border-radius-xl: 0; --bs-border-radius-xxl: 0; --bs-border-radius-pill: 0;
}
[data-bs-theme=dark] {
  --w-bg: #070F1A; --w-side: #0A1421; --w-card: #0D1826; --w-head: #0A1421;
  --w-rule: rgba(216,227,238,.14); --w-rule-hard: rgba(216,227,238,.34);
  --w-text: #E9EFF6; --w-dim: rgba(233,239,246,.62); --w-faint: rgba(233,239,246,.42);
  --w-hover: rgba(216,227,238,.06); --w-tile: #111E2E;
  --w-accent: #2BBFFF; --w-accent-ink: #9BE1FF; --w-accent-tint: rgba(43,191,255,.15);
  --w-glow: rgba(43,191,255,.10);
  --w-signal-red: #C8102E; --w-signal-green: #3AA346; --w-foam: #DCE4EC;
  --w360-chart-1: #0284c7; --w360-chart-2: #d97706; --w360-chart-3: #8b5cf6;
  /* CW-56: same validated pair as light (mode-invariant hex — validator
     ALL-PASS against the #0D1826 dark card surface too). */
  --w360-chart-4: #0d9488; --w360-chart-5: #dc2626;
  /* CW-56 Tier 3 (RETIRED for the heatmap by r3, kept declared): the dark-mode ramp's OWN
     validated steps (--ordinal, ALL CHECKS PASS against the #0D1826 dark card surface —
     independently tuned, never a flip of the light steps). */
  --w360-heat-1: #324f64; --w360-heat-2: #3f6b8b; --w360-heat-3: #4c89b5;
  --w360-heat-4: #59a7e0; --w360-heat-5: #66c7ff;
  /* CW-56 r3: the dark-mode diverging scale — the SAME 6 chromatic hexes as light (mode-
     invariant), each arm independently re-validated against the #0D1826 dark surface (ordinal
     ALL CHECKS PASS both arms; mild-red/mild-green adjacency CVD ΔE 7.5 (protan), normal-vision
     ΔE 21.3) — only the BUCKET assignment flips (dark->light reads as rising severity here,
     since brighter pops against the dark surface — the same reversal --w360-heat-1..5 above
     already carries). */
  --w360-diverge-1: #fb923c; --w360-diverge-2: #ea580c; --w360-diverge-3: #9a3412;
  --w360-diverge-4: var(--bs-tertiary-bg);
  --w360-diverge-5: #065f46; --w360-diverge-6: #059669; --w360-diverge-7: #10b981;
  --w360-heat-ink-dark: #041E42; --w360-heat-ink-light: #E9EFF6;
  /* (the --w360-* aliases inherit — they reference --w-* and follow the theme) */
  --bs-body-bg-rgb: 7,15,26;
  --bs-body-color-rgb: 233,239,246;
  --bs-emphasis-color-rgb: 233,239,246;
  --bs-secondary-bg-rgb: 17,30,46;
  --bs-tertiary-bg-rgb: 17,30,46;
  --bs-primary-rgb: 43,191,255;
  --bs-link-color-rgb: 43,191,255;
  --bs-link-hover-color-rgb: 155,225,255;
}

/* ------------------------------------------------------------- TYPOGRAPHY */

/* Archivo = the whole UI (BRAND v2 — Outfit is superseded); Roboto Mono
   stays for code/data. Both are self-hosted (fonts.css, font-src 'self'). */
body { font-family: 'Archivo', system-ui, sans-serif; }
h1, h2, h3, h4, h5, h6 { font-family: 'Archivo', system-ui, sans-serif; font-weight: 800; letter-spacing: -0.02em; }
code, kbd, pre, samp, .w360-mono { font-family: 'Roboto Mono', ui-monospace, monospace; }

/* ---------------------------------------------------- BUTTONS / LINKS / FOCUS */

/* Disabled is 45% opacity everywhere (component law), and no button carries
   Bootstrap's inset :active elevation — the law has no shadows. */
.btn {
  --bs-btn-disabled-opacity: .45;
  --bs-btn-active-shadow: none;
}
/* Primary actions on the azure accent; hover/active step to accent-ink.
   The compiled Bootstrap .btn-*-primary rules carry LITERAL #0d6efd (only
   .text-primary/.text-bg-primary read --bs-primary-rgb), so both variants
   have to be re-pointed by hand. */
.btn-primary {
  --bs-btn-bg: var(--w-accent);
  --bs-btn-border-color: var(--w-accent);
  --bs-btn-hover-bg: var(--w-accent-ink);
  --bs-btn-hover-border-color: var(--w-accent-ink);
  --bs-btn-active-bg: var(--w-accent-ink);
  --bs-btn-active-border-color: var(--w-accent-ink);
  --bs-btn-disabled-bg: var(--w-accent);
  --bs-btn-disabled-border-color: var(--w-accent);
}
.btn-outline-primary {
  --bs-btn-color: var(--w-accent);
  --bs-btn-border-color: var(--w-accent);
  --bs-btn-hover-bg: var(--w-accent);
  --bs-btn-hover-border-color: var(--w-accent);
  --bs-btn-active-bg: var(--w-accent-ink);
  --bs-btn-active-border-color: var(--w-accent-ink);
  --bs-btn-disabled-color: var(--w-accent);
  --bs-btn-disabled-border-color: var(--w-accent);
}
/* Dark mode's accent (#2BBFFF) and accent-ink (#9BE1FF) are LIGHT cyans, so
   Bootstrap's hardcoded #fff button ink drops to ~1.9:1 on them. Every place
   the accent becomes a FILL, the ink flips to the ground color (~9:1). The
   outline button's resting state is accent text on transparent and stays. */
[data-bs-theme=dark] .btn-primary {
  --bs-btn-color: var(--w-bg);
  --bs-btn-hover-color: var(--w-bg);
  --bs-btn-active-color: var(--w-bg);
  --bs-btn-disabled-color: var(--w-bg);
}
[data-bs-theme=dark] .btn-outline-primary {
  --bs-btn-hover-color: var(--w-bg);
  --bs-btn-active-color: var(--w-bg);
}
/* Links take the accent directly: --bs-link-color-rgb needs a literal triplet
   (rgba()'s slot form cannot take a var()), so the theme blocks above carry
   the triplets and this rule carries the token — they agree by construction. */
a { color: var(--w-accent); }
a:hover, a:focus { color: var(--w-accent-ink); }

/* Focus = 2px azure ring at 2px offset (component law). Bootstrap ships a
   .25rem #0d6efd glow; outline replaces it — an outline is a rule, not
   elevation, and it is layout-inert. */
.btn:focus-visible,
.btn-check:focus-visible + .btn,
a:focus-visible,
.nav-link:focus-visible,
.dropdown-item:focus-visible {
  box-shadow: none;
  outline: 2px solid var(--w-accent);
  outline-offset: 2px;
}
.form-control:focus,
.form-select:focus {
  /* Bootstrap's :focus rule re-asserts background-color:var(--bs-body-bg) at
     a higher specificity than the .form-control surface rule below, so the
     card surface has to be restated here or a focused field flashes to the
     page ground. */
  background-color: var(--w-card);
  border-color: var(--w-accent);
  box-shadow: 0 0 0 2px var(--w-accent-tint);
}
.form-check-input:focus {
  border-color: var(--w-accent);
  box-shadow: 0 0 0 2px var(--w-accent-tint);
}
.form-check-input:checked { background-color: var(--w-accent); border-color: var(--w-accent); }
/* Same light-accent problem as the buttons: Bootstrap's check/dot glyph is a
   WHITE data-URI SVG, which vanishes on the dark accent. The URI cannot take
   a var(), so the ground color (#070F1A) is inlined here — it is the dark
   --w-bg literal and moves only if that token moves. */
[data-bs-theme=dark] .form-check-input:checked[type=checkbox] {
  --bs-form-check-bg-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23070F1A' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='m6 10 3 3 6-6'/%3e%3c/svg%3e");
}
[data-bs-theme=dark] .form-check-input:checked[type=radio] {
  --bs-form-check-bg-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%23070F1A'/%3e%3c/svg%3e");
}

/* The one .text-bg-primary in the page set: Bootstrap hardcodes color:#fff,
   which fails on the dark-mode accent (#2BBFFF, ~1.9:1). Flip the ink. */
[data-bs-theme=dark] .text-bg-primary { color: var(--w-bg) !important; }

/* ------------------------------------------------------------- THE LOCKUP */

/* WASH (Archivo 800, -4% tracking, ink) + a 2px vertical rule at 22% ink +
   360 with a superscript degree (Archivo 500, accent). Pure markup + CSS —
   no SVG asset, no icon font (BRAND.md; D-062). The class names .w360-logo /
   .w360-logo-lg / .w360-logo-sm are a CONTRACT with the templates. */
.w360-logo { display: inline-flex; align-items: center; gap: 0.35em; line-height: 1; }
.w360-lockup-name { font-weight: 800; letter-spacing: -0.04em; color: var(--w-text); }
.w360-lockup-rule { width: 2px; align-self: stretch; background: rgba(4,30,66,.22); }
[data-bs-theme=dark] .w360-lockup-rule { background: rgba(233,239,246,.22); }
.w360-lockup-figures { font-weight: 500; color: var(--w-accent); }
.w360-lockup-figures sup { font-size: .55em; }
.w360-logo-lg { font-size: 2rem; }
.w360-logo-sm { font-size: 1.0625rem; } /* the brand book's 17px app-header size */

/* Legacy v1 lockup selectors — DEFINED BUT UNUSED by the v2 partial. Kept so
   a version-pinned page still holding the old droplet markup renders sanely
   (the droplet SVG itself is gone from logo.php). */
.w360-wordmark { font-size: 1em; font-weight: 800; letter-spacing: -0.04em; line-height: 1; color: var(--w-text); }
.w360-accent { color: var(--w-accent); }
.w360-drop { fill: var(--w-accent); }
.w360-logo svg { display: block; height: 1em; width: auto; }

/* --------------------------------------------------------------- CHROME */

/* Brand v2 chrome: flat ruled surfaces, no shadows, 252px sidebar.
   NO !important anywhere here: the templates dropped their bg-body-tertiary /
   bg-body / bg-body-secondary utilities (those ARE !important, so they could
   only have been beaten by another !important) — removing the utility is the
   cleaner cut. Sidebar colors go through AdminLTE's own --lte-sidebar-*
   hooks, because AdminLTE's selectors (.sidebar-wrapper .sidebar-menu >
   .nav-item > .nav-link…) outrank anything reasonable we could write. */
.app-sidebar {
  --lte-sidebar-width: 252px;
  --lte-sidebar-color: var(--w-dim);
  --lte-sidebar-hover-color: var(--w-text);
  --lte-sidebar-hover-bg: var(--w-hover);
  --lte-sidebar-active-color: var(--w-accent-ink);
  --lte-sidebar-menu-active-color: var(--w-accent-ink);
  --lte-sidebar-menu-active-bg: var(--w-accent-tint);
  --lte-sidebar-submenu-color: var(--w-dim);
  --lte-sidebar-submenu-hover-color: var(--w-text);
  --lte-sidebar-submenu-hover-bg: var(--w-hover);
  --lte-sidebar-submenu-active-color: var(--w-accent-ink);
  --lte-sidebar-submenu-active-bg: var(--w-accent-tint);
  --lte-sidebar-header-color: var(--w-faint);
  background: var(--w-side);
  border-right: 2px solid var(--w-rule-hard);
  box-shadow: none;
}
/* The 13 color hooks MUST be repeated under the dark selector. AdminLTE ships
   `[data-bs-theme=dark] .app-sidebar,[data-bs-theme=dark].app-sidebar { … }`
   redeclaring exactly these 13 (everything except --lte-sidebar-width) at
   specificity (0,2,0) — that beats the (0,1,0) block above no matter how late
   brand.css loads, so in dark mode all 13 would silently fall back to
   AdminLTE's grey/white defaults and only the 3px accent bar would survive.
   Both selector forms are mirrored so this block ties on specificity and wins
   on source order. The values are identical: they are var(--w-*) references,
   which already resolve per theme. */
[data-bs-theme=dark] .app-sidebar,
[data-bs-theme=dark].app-sidebar {
  --lte-sidebar-color: var(--w-dim);
  --lte-sidebar-hover-color: var(--w-text);
  --lte-sidebar-hover-bg: var(--w-hover);
  --lte-sidebar-active-color: var(--w-accent-ink);
  --lte-sidebar-menu-active-color: var(--w-accent-ink);
  --lte-sidebar-menu-active-bg: var(--w-accent-tint);
  --lte-sidebar-submenu-bg: transparent;
  --lte-sidebar-submenu-color: var(--w-dim);
  --lte-sidebar-submenu-hover-color: var(--w-text);
  --lte-sidebar-submenu-hover-bg: var(--w-hover);
  --lte-sidebar-submenu-active-color: var(--w-accent-ink);
  --lte-sidebar-submenu-active-bg: var(--w-accent-tint);
  --lte-sidebar-header-color: var(--w-faint);
}
.app-header {
  background: var(--w-head);
  border-bottom: 1px solid var(--w-rule);
  box-shadow: none;
}
/* Brand v2 has NO elevation anywhere. Bootstrap's .shadow* utilities are
   themselves !important, so they can only be answered in kind — and the
   brand layer is the RIGHT home for the answer rather than a sweep of the
   page templates: 54 `.shadow-sm` classes live across 16 page fragments
   today, and any page added later still obeys the law for free. (Removing
   the now-inert classes from the fragments is cosmetic debt for a later
   task, not a behavior change.) */
.shadow, .shadow-sm, .shadow-lg { box-shadow: none !important; }
.card {
  --bs-card-bg: var(--w-card);
  --bs-card-border-color: var(--w-rule);
  --bs-card-cap-bg: var(--w-tile);
  --bs-card-cap-color: var(--w-text);
  --bs-card-box-shadow: none;
  background: var(--w-card);
  border: 1px solid var(--w-rule);
  box-shadow: none;
}
.card:hover { --bs-card-border-color: var(--w-rule-hard); border-color: var(--w-rule-hard); }
/* The 3px accent active bar (BRAND: "nav row with a 3px accent active bar").
   The background/color for .active arrive via the hooks above. */
.sidebar-menu .nav-link { border-left: 3px solid transparent; }
.sidebar-menu .nav-link.active { border-left-color: var(--w-accent); }
.nav-header { letter-spacing: .1em; }
/* Sidebar brand block breathing room (pre-v2, retained) + the 2px region
   rule under it. */
.sidebar-brand { border-bottom: 2px solid var(--w-rule-hard); }
.sidebar-brand .brand-link { display: flex; align-items: center; padding: .8125rem .5rem; }
/* Menus, modals and tables all default to --bs-body-bg (= the page ground);
   re-point them at the card surface so they read as raised panels. */
.dropdown-menu {
  --bs-dropdown-bg: var(--w-card);
  --bs-dropdown-border-color: var(--w-rule);
  --bs-dropdown-link-hover-bg: var(--w-hover);
  --bs-dropdown-link-active-bg: var(--w-accent-tint);
  --bs-dropdown-link-active-color: var(--w-accent-ink);
  --bs-dropdown-box-shadow: none;
}
.modal {
  --bs-modal-bg: var(--w-card);
  --bs-modal-border-color: var(--w-rule);
  --bs-modal-header-border-color: var(--w-rule);
  --bs-modal-footer-border-color: var(--w-rule);
  --bs-modal-box-shadow: none;
}
/* Table cells inherit --bs-table-bg, which Bootstrap seeds from --bs-body-bg;
   left alone every table would paint itself the page ground INSIDE a white
   card. Transparent lets the card surface show through; the striped/hover/
   section states still work (they ride --bs-table-bg-type/-state). */
.table { --bs-table-bg: transparent; --bs-table-border-color: var(--w-rule); }
/* Inputs: the card surface with a 1px rule (BRAND: "Inputs 38px, 1px rule").
   Same reason as the table — the Bootstrap default is the page ground. */
.form-control, .form-select {
  background-color: var(--w-card);
  border-color: var(--w-rule-hard);
}
/* Pagers (manage.js builds one on /users /locations /operators /roles;
   account.js one for sessions) — same --bs-body-bg trap as the table, PLUS a
   literal #0d6efd active pill and focus ring. */
.pagination {
  --bs-pagination-color: var(--w-accent);
  --bs-pagination-bg: var(--w-card);
  --bs-pagination-border-color: var(--w-rule);
  --bs-pagination-hover-color: var(--w-accent-ink);
  --bs-pagination-hover-bg: var(--w-hover);
  --bs-pagination-hover-border-color: var(--w-rule-hard);
  --bs-pagination-focus-color: var(--w-accent-ink);
  --bs-pagination-focus-bg: var(--w-hover);
  --bs-pagination-focus-box-shadow: 0 0 0 2px var(--w-accent-tint);
  --bs-pagination-active-bg: var(--w-accent);
  --bs-pagination-active-border-color: var(--w-accent);
  --bs-pagination-disabled-color: var(--w-faint);
  --bs-pagination-disabled-bg: var(--w-card);
  --bs-pagination-disabled-border-color: var(--w-rule);
}
/* The three list-groups on /location-labels sit inside cards — same trap. */
.list-group {
  --bs-list-group-color: var(--w-text);
  --bs-list-group-bg: var(--w-card);
  --bs-list-group-border-color: var(--w-rule);
  --bs-list-group-action-color: var(--w-dim);
  --bs-list-group-action-hover-color: var(--w-text);
  --bs-list-group-action-hover-bg: var(--w-hover);
  --bs-list-group-action-active-color: var(--w-text);
  --bs-list-group-action-active-bg: var(--w-hover);
  --bs-list-group-disabled-color: var(--w-faint);
  --bs-list-group-disabled-bg: var(--w-card);
  --bs-list-group-active-bg: var(--w-accent);
  --bs-list-group-active-border-color: var(--w-accent);
}
/* Dark-ink discipline (§ the buttons above): both active fills are the accent,
   which in dark is a LIGHT cyan — Bootstrap's #fff ink would drop to ~1.9:1. */
[data-bs-theme=dark] .pagination { --bs-pagination-active-color: var(--w-bg); }
[data-bs-theme=dark] .list-group { --bs-list-group-active-color: var(--w-bg); }

/* ---- Card layout (pre-auth pages: /login /forgot /setup /reset + errors),
   CW-12 capstone CP-3…CP-6. Scoped to .login-page/.login-box — the app
   shell (layout-app) is untouched by everything below. ---- */

/* iOS Safari's 100vh includes the collapsed toolbar area, so the flex-
   centered card sits too low (extra grey above). dvh tracks the VISIBLE
   viewport; the 100vh line stays FIRST as the fallback for engines without
   dvh. Desktop is unchanged (100dvh == 100vh when there is no dynamic
   browser chrome). Needs on-device iOS validation (CP-3h). */
.login-page {
  min-height: 100vh;
  min-height: 100dvh;
}

/* Divider between the logo and the card heading (layout-card) — now the
   brand's 1px rule token. */
.w360-card-divider {
  margin: 0 0 1.25rem;
  border-top: 1px solid var(--bs-border-color);
  opacity: 1;
}

/* Card headings are real <h1>s for document structure but sized as card
   titles. Scoped to .login-box so app-shell h1 page titles keep their own
   scale (different layout, no .login-box there). */
.login-box h1 {
  font-size: 1.375rem;
}

/* In-field trailing icons (decorative, aria-hidden) on the card forms.
   The <i> sits inside the .form-floating wrapper; the input reserves right
   padding so text never underlaps. The icon is anchored to the input's
   fixed 3.5rem box — NOT centered on the wrapper, which grows when
   .invalid-feedback renders below the field. */
.w360-icon-field > .form-control {
  padding-right: 2.5rem;
}
.w360-icon-field > .w360-field-icon {
  position: absolute;
  top: calc((3.5rem + var(--bs-border-width) * 2) / 2);
  right: .875rem;
  transform: translateY(-50%);
  color: var(--bs-secondary-color); /* re-based to --w-dim in the token block */
  pointer-events: none;
}
/* Bootstrap's .is-invalid feedback glyph occupies the same right-edge slot;
   the decorative icon yields to it (padding-right above still holds). */
.w360-icon-field > .form-control.is-invalid ~ .w360-field-icon {
  display: none;
}

/* ---- Breadcrumbs (CW-13 capstone CP-13). The layout-app slot sits in
   .app-content-header directly under the page H1; shell.renderBreadcrumbs()
   fills it post-me-check. The divider is '/' — Stephen's validation-round
   ruling, which is Bootstrap's default, so NO --bs-breadcrumb-divider
   override exists here (deleting the earlier '>' override IS the fix). ---- */
[data-w360="breadcrumb"] {
  margin-top: .25rem;
}

/* CW-13 validation round (Stephen): AdminLTE's .card-header::after clearfix
   becomes a THIRD flex item inside d-flex headers, so justify-content:
   space-between parked the header button in the MIDDLE (his "centered-ish").
   Kill the pseudo-element in flex headers only — non-flex card headers keep
   their clearfix. Vendored CSS untouched. */
.card-header.d-flex::after {
  content: none;
}

/* ---- /account (CW-12 capstone CP-9). Scoped to the account page's own
   classes — nothing below touches the shared shell or other pages. ---- */

/* A true 30/70 Profile/Sessions split on large screens — the 12-col grid
   cannot express 30% (col-4 = 33.3%, col-7 = 58.3%). The columns keep the
   Bootstrap col base class (gutter padding + the col-12 full-width stack
   below lg); these override flex-basis/max-width at lg+ only. */
@media (min-width: 992px) {
  .w360-account-30 { flex: 0 0 30%; max-width: 30%; }
  .w360-account-70 { flex: 0 0 70%; max-width: 70%; }
}

/* Equal-height card headers: the sessions header carries a btn-sm (~31px)
   that Profile's text-only header lacks — a shared flex box with one
   min-height (3rem = the btn-sm header's natural height) pins BOTH to the
   exact same size; content stays vertically centered. The button carries
   ms-auto in the template, so it sits flush against the header's right
   padding edge. */
.w360-account-30 > .card > .card-header,
.w360-account-70 > .card > .card-header {
  display: flex;
  align-items: center;
  min-height: 4rem;
}

/* Profile dl breathing room (the profile column is the height driver —
   see the avatar note below): dd bottom margins open the rows up from
   Bootstrap's default .5rem. */
.w360-account-30 dl > dd {
  margin-bottom: .75rem;
}

/* Initials avatar: the /account profile circle + the small header-dropdown
   variant. Brand accent disc, white initials (textContent initials or the
   CP-12 createElement <img> fill from the me payload — never HTML markup).
   Sized 108px so the PROFILE column drives the equal-height pair with
   slack: 8 session rows + the pager must fit inside it (Stephen's CW-12
   height budget); the header variant keeps its own box below.
   The 50% radius SURVIVES the zero-radius law: the disc is GEOMETRY (an
   avatar is a circle), not decoration. */
.w360-avatar {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 108px;
  height: 108px;
  border-radius: 50%;
  background-color: var(--w360-accent);
  color: #fff;
  font-family: 'Archivo', system-ui, sans-serif;
  font-size: 2.625rem;
  font-weight: 600;
  line-height: 1;
  user-select: none;
}
/* Dark mode flips the disc ink: the dark accent (#2BBFFF) is a LIGHT cyan,
   so the v1 white initials would land at ~1.9:1 on it. Ground-colored ink
   on the accent disc reads ~9:1 in both themes. */
[data-bs-theme=dark] .w360-avatar { color: var(--w-bg); }
.w360-avatar-sm {
  width: 2.25rem;
  height: 2.25rem;
  font-size: 1rem;
}

/* ---- CP-12 avatar photo + crop modal (CW-13). The photo <img> fills the
   EXACT .w360-avatar boxes above (108px card / 2.25rem header) — the CW-12
   height budget is untouched; the circle simply gains a photo fill (JS
   builds the img via createElement with an empty alt). ---- */
.w360-avatar-img {
  display: block;
  width: 100%;
  height: 100%;
  border-radius: 50%; /* geometry, as above */
  object-fit: cover;
}

/* CW-13 header-avatar fix (Stephen live-catch): a baseline-less <img> child
   made the inline-flex circle sit ON the username's baseline (an inline-flex
   box with no text baseline uses its bottom edge) — the toggle anchor is now
   a flex row (template d-flex align-items-center) so circle + name center
   deterministically for BOTH variants; the header photo gains a brand ring
   so it reads against the navbar; sm bumped 1.75 → 2.25rem for legibility.
   Stephen round 2: ring 2px → 1px, and the toggle is py-0 (template) so the
   2.25rem circle stops driving the navbar height — the ring is box-shadow
   (layout-inert); the CIRCLE was what nudged the bar.
   BRAND v2 note: this box-shadow is a 1px RING (a rule that happens to be
   layout-inert), NOT elevation — the no-shadows law is about elevation. It
   stays box-shadow deliberately: `border` would resize the circle and
   re-break the navbar height, and `outline` only follows border-radius on
   Safari 16.4+. */
.w360-avatar-sm > .w360-avatar-img {
  box-shadow: 0 0 0 1px var(--w360-accent);
}

/* The crop preview: a 288px CSS square canvas (2x internal backing for
   crisp hi-dpi drawing; shrinks fluidly inside narrow-phone modals) panned
   by pointer drag — touch-action none keeps touch drags on the canvas. */
.w360-crop-box {
  position: relative;
  width: 288px;
  max-width: 100%;
  margin: 0 auto;
}
.w360-crop-canvas {
  display: block;
  width: 100%;
  height: auto;
  touch-action: none;
  cursor: move;
  background-color: var(--bs-secondary-bg); /* re-based to --w-tile */
  border-radius: 0;
}

/* The circular mask: dim everything outside the inscribed circle (= the
   saved crop; closest-side of a square is exactly that circle). Pointer-
   inert so drags fall through to the canvas beneath. The MASK CIRCLE is
   geometry and stays round; the box corners go square with everything else. */
.w360-crop-mask {
  position: absolute;
  inset: 0;
  pointer-events: none;
  border-radius: 0;
  background: radial-gradient(circle closest-side, transparent calc(100% - 1px), rgba(4, 30, 66, .45) 100%);
}

/* The zoom slider row under the preview (label + range). */
.w360-crop-zoom {
  display: flex;
  align-items: center;
  gap: .75rem;
  width: 288px;
  max-width: 100%;
  margin: .75rem auto 0;
}

/* ---- /dashboard (CW-16, Dashboard v1). Scoped to w360-dash/w360-chart/
   w360-filter classes — nothing below touches the shared shell. ---- */

/* Chart.js needs a positioned, sized parent for responsive canvases; the
   fixed height INCLUDES the x-axis band (dataviz anti-pattern: a container
   that clips the axis labels grows a nested scrollbar). */
.w360-chart-box {
  position: relative;
  height: 240px;
}

/* The line card's box GROWS to fill its h-100 card (Stephen's CW-16 ruling:
   the graph fits its entire card); the flex-column card-body provides the
   track, Chart.js (responsive, no aspect lock) tracks the box. */
.w360-chart-fill {
  flex: 1 1 auto;
  height: auto;
  min-height: 240px;
}

/* The donut's center total — a DOM overlay (CSP-clean, no canvas-text
   plugin), pointer-inert so Chart.js hover keeps working underneath. */
.w360-donut-center {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  pointer-events: none;
}

/* The donut's direct-label rows (the legend area lives in the DOM, not on
   the canvas: label + value stay selectable text and the swatches track the
   --w360-chart-* custom properties for free on theme flips). */
.w360-chart-legend {
  display: flex;
  flex-direction: column;
  gap: .25rem;
  margin-top: .75rem;
}
.w360-chart-legend .w360-legend-row {
  display: flex;
  align-items: center;
  gap: .5rem;
}
.w360-legend-swatch {
  display: inline-block;
  width: .75rem;
  height: .75rem;
  border-radius: 0;
  flex: 0 0 auto;
}

/* Numeric table columns align on tabular figures (dataviz: tabular-nums
   only where numbers stack vertically — never on the big center figure). */
.w360-num {
  font-variant-numeric: tabular-nums;
}

/* Table section-divider rows ('Top 7' / 'Bottom 7' in the growth card):
   a token-driven band that tracks the theme. Bootstrap's .table-light is
   OFF-LIMITS here — it pins --bs-table-bg to #f8f9fa in BOTH themes, so
   under [data-bs-theme=dark] its text-body-secondary label goes light-gray
   on near-white (~1.2:1). --bs-tertiary-bg is re-based to --w-tile in the
   token block above (#F7F9FB light / #111E2E dark), so the band is now a
   brand surface in both themes; the cells read the property from the row. */
.w360-table-section {
  --bs-table-bg: var(--bs-tertiary-bg);
}

/* Refetch hold (dataviz interaction rule): while a card refetches, its body
   keeps the previous render at reduced opacity — no skeleton flash. */
.w360-busy {
  opacity: .5;
  pointer-events: none;
}

/* Multi-select dropdown menus in the filter bar: cap + scroll long option
   lists (24 locations today, more later). */
.w360-filter-menu {
  max-height: 60vh;
  overflow-y: auto;
  min-width: 14rem;
}

/* Stephen's CW-16 sizing rulings (batch 2 amended batch 1): spacing +150%
   (.5rem -> 1.25rem); control boxes re-cut LOWER (-25% height: slim vertical
   padding) and LONGER (+35%: wider horizontal padding + a floor width on the
   dimension dropdowns). Apply/Clear share the height so the bar stays level. */
.w360-filter-bar {
  gap: 1.25rem;
}
.w360-filter-bar .btn {
  font-size: 1rem;
  padding: 0.2rem 1.42rem;
}
.w360-filter-bar .dropdown-toggle {
  min-width: 9.5rem;
}

/* The dash rows' card headers align their bottoms: the donut/growth/tenure
   headers carry a month select that would otherwise sit taller than the
   Revenue/Members headers — one uniform header height levels the borders.
   Shared by BOTH dashboard rows (the CW-16 members row mirrors revenue). */
[data-w360="dash-row"] .card-header,
[data-w360="members-row"] .card-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: .5rem;
  min-height: 3rem;
  padding-top: .25rem;
  padding-bottom: .25rem;
}

/* ---- /dashboards — the catalog landing page (CW-26, Plan 1; KICKOFF §3).
   Scoped to w360-catalog-* classes — nothing below touches the shared
   shell or other pages. Cards render for EVERY catalog entry, live AND
   planned: this page is the full 18-dashboard roadmap map; only the
   SIDEBAR hides planned entries (shell.js §7.6 ruling, D-064). ---- */
.w360-catalog-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
  gap: 20px;
}
.w360-catalog-card {
  display: flex;
  align-items: flex-start;
  gap: .75rem;
  padding: 1rem;
  background: var(--w-card);
  border: 1px solid var(--w-rule);
  color: inherit;
  text-decoration: none;
}
.w360-catalog-card:hover {
  border-color: var(--w-accent);
}
/* Planned cards render inert (div, not <a> — no href to hover toward);
   the border still steps on hover so the whole grid reads consistently. */
a.w360-catalog-card:hover .w360-catalog-title {
  color: var(--w-accent-ink);
}
.w360-catalog-tile {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: 0 0 auto;
  width: 2.5rem;
  height: 2.5rem;
  background: var(--w-tile);
}
.w360-catalog-tile i {
  font-size: 20px;
  color: var(--w-accent);
}
.w360-catalog-body {
  display: flex;
  flex-direction: column;
  gap: .25rem;
  min-width: 0;
}
.w360-catalog-title {
  font-weight: 800;
  color: var(--w-text);
}
.w360-catalog-desc {
  color: var(--w-dim);
  font-size: .875rem;
}
.w360-catalog-tags {
  display: flex;
  flex-wrap: wrap;
  gap: .375rem;
  margin-top: .125rem;
}
/* Group tag (BRAND.md .w360-tag): 800 weight, 9.5px, +10% (.1em) tracking,
   uppercase — a fixed transform so the class stays correct regardless of
   the catalog copy's own casing. */
.w360-tag {
  font-weight: 800;
  font-size: 9.5px;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--w-dim);
  background: var(--w-tile);
  padding: .1875rem .5rem;
}
/* Planned-state tag: an outline in --w-faint, no fill — visually
   subordinate to the live group tag it sits beside. */
.w360-tag-planned {
  color: var(--w-faint);
  background: transparent;
  border: 1px solid var(--w-faint);
}
.w360-catalog-planned {
  cursor: default;
}

/* ---- Plan 3 dashboards (Daily Flash / Hourly Wash Tracker / Membership Detail;
   Task 9, D-069). Scoped to w360-dash-table/w360-sort-btn/w360-td-sticky classes —
   nothing below touches the shared shell or other pages. SSAI reuse is PATTERN-ONLY
   (D-071 R8) — this is an original reproduction, zero code ported. ---- */

/* The pinned first column (site/portfolio/district/facility label): sticky within the
   surrounding .table-responsive's horizontal scroll. --w-card (not --bs-body-bg, which
   .table resets to transparent above) so the sticky cell paints an OPAQUE surface —
   scrolled-under columns must not show through the pinned one. z-index lifts it above
   the scrolling cells; the section-header row (colspan) is exempt (it spans the whole
   table width already, so pinning its own corner cell independently is unnecessary —
   rowSpan carries it). */
.w360-dash-table td.w360-td-sticky,
.w360-dash-table th.w360-td-sticky {
  position: sticky;
  left: 0;
  z-index: 2;
  background-color: var(--w-card);
}
/* The section-band rows (.w360-table-section, e.g. the packages "Blended Avg" row)
   repaint their own sticky cell to match the band, not the plain card surface, or the
   pinned column would visibly seam against the row it belongs to. */
.w360-table-section td.w360-td-sticky {
  background-color: var(--bs-tertiary-bg);
}

/* Sortable column headers: a plain-text button (no layout shift when aria-sort changes)
   with a CSS-drawn arrow — currentColor tracks the header's own ink in both themes, no
   separate icon asset. */
.w360-sort-btn {
  color: inherit;
  font-weight: 600;
}
.w360-dash-table th[aria-sort="ascending"] .w360-sort-btn::after {
  content: " \25B2";
}
.w360-dash-table th[aria-sort="descending"] .w360-sort-btn::after {
  content: " \25BC";
}

/* The collapsible section-group header row (Wash Volume/Revenue/Memberships/Labor/
   Revenue Detail) — a token-driven band (the w360-table-section precedent) so the
   grouping reads distinctly from the sub-header row beneath it in both themes. */
.w360-section-header {
  /* CW-39 review fix (IMPORTANT-1): the bare background-color at (0,1,0) LOSES
     to Bootstrap's compiled cell rule `.table>:not(caption)>*>*` at (0,1,1)
     (which paints --bs-table-bg, transparent per the .table reset above) — so
     this tint never painted statically, and the CW-39 pinned-state override
     made it appear only while pinned: a state-dependent band color. The
     .w360-table-section custom-property idiom is the root fix: the cell reads
     --bs-table-bg from itself, and static + pinned now agree. */
  --bs-table-bg: var(--bs-tertiary-bg);
  background-color: var(--bs-tertiary-bg);
  border-bottom: 1px solid var(--w-rule);
}

/* The ⚠-no-budget affordance (D-069 corollary: "no budget, site not open" vs "budget
   missing, someone should look" must be VISUALLY DISTINCT — both are blanks). The glyph
   difference (⚠ — vs plain —) already carries the distinction without color; the call
   site pairs it with Bootstrap's OWN semantic .text-warning-emphasis (already theme-
   aware, both directions) rather than a new token here — this hook class carries no
   color of its own, so the two never fight over which one wins. */
.w360-gap-flag {
  cursor: help;
}

/* CW-33 Daily Flash LAYOUT REBUILD (Chris's ruling — mirror the SSAI Daily Flash's
 * per-section card style; PATTERN-ONLY, D-071 R8): each section is now its own card
 * with its OWN period-major (MTD/Budget/PMTD/PYMTD) two-row-header table. Band
 * separators — a heavier left rule at each band's FIRST column, in both the band-header
 * row and every sub-header/body row beneath it — so the bands read as distinct blocks
 * while scrolling, not a wall of undifferentiated columns. --w-rule-hard is the SAME
 * token .app-sidebar's border-right / .card:hover already use for a heavier-than-default
 * rule — no new token introduced. */
.w360-band-start {
  border-left: 2px solid var(--w-rule-hard);
}

/* CW-35 Item 1 (owner-ruled): the pinned summary row (Districts/Facilities
   tables, BOTH flash.js and hourly.js) — bold + a heavier top rule so it
   reads as a totals row without a new color, composing cleanly with
   .w360-band-start's own left rule (distinct sides, same --w-rule-hard
   token). Never rendered on Portfolio view (there is nothing to summarize
   beyond the single row already shown). */
.w360-summary-row > td {
  font-weight: 600;
  border-top: 2px solid var(--w-rule-hard);
}

/* CW-35 Item 2/3a (owner-ruled): the budgetDerived affordance — a cell whose
   BvA figure is CALCULATED from other budget lines (carsMember = total -
   single; ticketSingle = rev_single / cars_single), never directly
   imported. A small superscript, the SAME "hook carries no color of its
   own" idiom as .w360-gap-flag above — cursor:help pairs it with a title
   attribute at the call site, no new token. */
.w360-derived-flag {
  cursor: help;
  font-size: 0.65em;
  vertical-align: super;
  margin-left: 1px;
  color: var(--w-dim);
}

/* CW-38 (D-084 ADJUDICATION A, Chris ruled): the partial-coverage superscript
   on memberships-family value cells — the group figure sums covered sites only
   (a brand-new store is excluded until its first fully-stamped window). Same
   "hook carries no color drama of its own" idiom as .w360-derived-flag; the
   title attribute at the call site carries the explanation. */
.w360-partial-flag {
  cursor: help;
  font-size: 0.65em;
  vertical-align: super;
  margin-left: 1px;
  color: var(--w-dim);
}

/* CW-56 (Task 12, CRM Performance campaigns table): a campaign whose vendor
   conversion window (banner.conversionWindowDays — never a hardcoded literal
   in code or copy) has not fully elapsed since its last send — its
   conversions/revenue may still climb. Same superscript-flag idiom as
   .w360-partial-flag/.w360-derived-flag (title attribute at the call site
   carries the window text) PLUS a subtle row tint — reusing --w-hover (the
   existing interactive-row overlay token, both themes) rather than a new one. */
.w360-maturing-flag {
  cursor: help;
  font-size: 0.65em;
  vertical-align: super;
  margin-left: 1px;
  color: var(--w-dim);
  /* CW-56 Tiers 1+2 (Chris flagged): the flag sits inside a flex row (nameLine) alongside a
     middle-ellipsis name — inline-flex + a fixed flex-basis means the glyph itself can never
     wrap onto its own line no matter how tight the row gets, unlike a plain inline element
     which a squeezed flex container could still break across two lines. */
  display: inline-flex;
  flex: 0 0 auto;
}
.w360-maturing-row > td {
  background-color: var(--w-hover);
}

/* CW-56 Tiers 1+2 (campaign families, Ruling R25): the expand/collapse chevron — a plain-text
   button (no layout shift, the same "ghost button, no icon asset" idiom as .w360-sort-btn) that
   toggles one family row's own child rows, never a column's sort state. */
.w360-family-chevron {
  color: inherit;
  width: 1.1em;
  flex: 0 0 auto;
}

/* A family's own campaign x site child rows: indented one step under the family's sticky name
   column, and a hair off the plain card surface (the SAME token .w360-table-section already
   uses) so the grouping reads at a glance — this is a rollup relationship, not a totals row, so
   it deliberately stops short of that idiom's heavier band/rule treatment. */
.w360-family-child-name {
  padding-left: 1.75rem;
}
/* CW-56 r3: the drill's THIRD level (a campaign x site row nested under an expanded family,
   itself nested under an expanded group) — one further indent step. */
.w360-family-grandchild-name {
  padding-left: 3.25rem;
}
.w360-family-child-row > td {
  background-color: var(--bs-tertiary-bg);
}
/* The sticky name cell needs its OWN override at the same specificity as the generic
   .w360-dash-table td.w360-td-sticky rule above (two classes) — the .w360-table-section
   precedent this mirrors exactly, same reason: the plain .w360-family-child-row > td rule alone
   loses that fight and the pinned column would visibly seam against its own row. */
.w360-family-child-row td.w360-td-sticky {
  background-color: var(--bs-tertiary-bg);
}

/* CW-56 Tier 3 / r3 (campaign group x site effectiveness heatmap): the table IS the chart (a
   plain HTML table with colored td backgrounds, no chart library) — value cells share
   .w360-num's tabular-nums discipline. Background/ink colors are set inline per cell
   (dashboard.js's `swatch.style.backgroundColor = 'var(...)'` idiom — tracks a theme flip for
   free); this class carries only the shape. CW-56 r3 layout fix (Chris flagged a screenshot
   showing 6-line wrapped row labels): a uniform compact cell size, never per-cell auto-sizing —
   the grid must read as a GRID, not a ragged table. */
.w360-heat-cell {
  font-variant-numeric: tabular-nums;
  text-align: end;
  white-space: nowrap;
  min-width: 5.5rem;
  height: 2.25rem;
}
/* CW-56 r3: row labels (now the fixed campaign groups — 8 as of TAXONOMY v2) and column labels (site codes) stay
   SINGLE-LINE — ellipsis + a title tooltip carries the full text, never a wrapped multi-line
   label (Chris's own screenshot flag against the pre-r3 heatmap). Combined with the existing
   .w360-td-sticky pinned-first-column rule (D-071 R8, above) so horizontal scroll never loses
   the row identity. */
.w360-heat-row-label {
  max-width: 12rem;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.w360-heat-col-label {
  max-width: 6rem;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* The legend row beneath the metric toggle (Chris ruled: a compact red<->green key). Swatch
   colors are set inline from the SAME --w360-diverge-N tokens the grid cells paint with, so the
   legend can never silently drift out of sync with the actual ramp. */
/* CW-56 TAXONOMY v3 (Chris ruled): a row whose own baseline is under the portfolio floor keeps
   its VALUES and loses its COLOUR. The background is set inline to the neutral ramp step (the
   same surface the at-average bucket uses); this class carries only the "not a graded cell"
   affordance — a muted italic value, so a suppressed cell stays distinguishable from a genuinely
   at-average one without introducing a second colour scale. */
.w360-heat-nocolor {
  font-style: italic;
  opacity: 0.85;
}
.w360-legend-swatch {
  display: inline-block;
  width: 0.85rem;
  height: 0.85rem;
  border: 1px solid var(--w-rule);
}
/* A family that never ran at a site: EXPLICITLY empty — a diagonal hatch over the neutral rule
   token (theme-following, never a --w360-heat-N ramp step) so "no activity" can never be
   mistaken for "activity, just low" (the low-N cell below, which is a flat neutral wash with no
   hatch). */
.w360-heat-empty {
  background-image: repeating-linear-gradient(45deg, var(--w-rule) 0, var(--w-rule) 1px, transparent 1px, transparent 6px);
  background-color: var(--bs-tertiary-bg);
  color: var(--w-faint);
}
/* Present, but under the campaign table's OWN small-N floor (REVENUE_PER_100_MIN_DELIVERED) —
   a flat neutral wash, NEVER a --w360-heat-N ramp color: thin data must never read as
   confidently "hot" or "cold" just because a color was assigned. */
.w360-heat-low-n {
  background-color: var(--bs-tertiary-bg);
  color: var(--w-dim);
}
/* forced-colors mode overrides custom backgrounds to system colors by default — this rule just
   makes that explicit for the heat cells specifically, so a reader in that mode still gets a
   legible bordered cell rather than a borderless run of system-background text (the table
   itself, and every cell's textContent value, already satisfies "values remain as text" by
   construction — this is belt-and-suspenders). */
@media (forced-colors: active) {
  .w360-heat-cell {
    forced-color-adjust: none;
    background-color: Canvas;
    color: CanvasText;
    border: 1px solid CanvasText;
  }
}

/* ---- CW-39: per-section scroll pinning (the SSAI table-pinning port — BACKLOG P1,
   Chris ruled 2026-08-05). ADAPTED per D-071 R8 pattern-only: original code — the
   canonical measuring-JS + CSS system (SSAI table-scroll.js/css, 2026-07-30)
   reproduced against Bootstrap/brand.css. table-scroll.js measures each thead row,
   writes its cells' `top` inline, and publishes .w360-js-pinned/.w360-js-contained.

   THE FAILURE PHILOSOPHY (load-bearing, from the canon): the 70vh CAGE is gated on
   .w360-js-pinned — the JS's own success signal — never on the bare container class.
   position:sticky with top:auto pins NOTHING, so a cage without the JS would be a
   70vh box whose headers scroll away: strictly worse than no asset. No JS (404, CSP,
   error) -> exactly today's behavior.

   DIVERGENCES from the canon, deliberate (adapt, don't byte-copy — the CW-37 resize):
   - the CORNER is host-NAMED (.w360-td-sticky on the thead label cell — every
     Wash360 dash table already marks it) instead of JS-derived; the canon derived
     it because its adopters' thead shapes disagreed. Ours don't.
   - the opaque flatten is token-DETERMINISTIC (--w-card / --bs-tertiary-bg — opaque
     hexes in BOTH themes, verified at their declarations above) instead of
     JS-measured translucency; the canon measured because its glassmorphic hosts
     differed per adopter. ---- */

.w360-scroll-pinned {
  overflow: auto; /* .table-responsive gives overflow-x only; the cage needs both
                     axes. Same specificity (0,1,0) — brand.css loads after the
                     Bootstrap vendor sheet, so the tie falls our way (the canon's
                     load-order rule, reproduced). */
}
.w360-scroll-pinned.w360-js-pinned {
  max-height: 70vh; /* the cage — ONLY on the JS's signal (see header comment) */
}
/* Containment ONLY when the container really overflows (.w360-js-contained is
   measured by the JS): `overscroll-behavior: contain` on a NON-overflowing
   container is a wheel DEAD ZONE — the engine treats it as already at its scroll
   limit and forbids chaining, freezing the wheel outright (the canon's 2026-07-30
   production regression on its short views). Never fold this into .w360-js-pinned. */
.w360-scroll-pinned.w360-js-contained {
  overscroll-behavior: contain;
}

/* Every header cell pins to the top of the scrollport; `top` arrives inline from
   the JS. Flatten to the card surface — Bootstrap .table cells are TRANSPARENT, so
   a pinned header would otherwise show the data rows scrolling straight through it.
   Gated on the JS signal: without it these cells keep today's static behavior. */
.w360-scroll-pinned.w360-js-pinned thead th {
  position: sticky;
  z-index: 4;
  background-color: var(--w-card);
}
/* The band-header row keeps its own (opaque) tint — the flatten above would
   otherwise repaint it to the plain card surface. (0,3,2) beats the flatten's
   (0,2,2) (review fix: the first cut of this comment miscounted it (0,3,1)). */
.w360-scroll-pinned.w360-js-pinned thead th.w360-section-header {
  background-color: var(--bs-tertiary-bg);
}
/* The CORNER (the host-named .w360-td-sticky label header) pins on BOTH axes and
   outranks the header band, holding the rectangle where the two sticky bands cross
   (position:sticky + left:0 already arrive from the D-071 R8 base rule above).
   Layering: corner 6 > the thead band's 4 > the body sticky column's 2.
   The bottom hairline inset (review fix, IMPORTANT-2): flash's corner spans BOTH
   header rows (rowSpan=2, tr:first-child), so the tr:last-child inset below never
   reaches it — without this, the pinned header's bottom rule stops dead at the
   widest column. On single-row theads (hourly/membership-detail) the tr:last-child
   rule at (0,3,3) beats this (0,3,2) with the SAME value — no divergence. */
.w360-scroll-pinned.w360-js-pinned thead th.w360-td-sticky {
  z-index: 6;
  box-shadow: inset 0 -1px 0 var(--w-rule);
}
/* Flash's row-1 band headers (every one is .w360-section-header AND
   .w360-band-start): the row1->row2 divider that Bootstrap's collapsed
   border-bottom paints statically can vanish when the cell goes sticky
   (engine-dependent) — carry it as an inset, COMPOSITED with the band-start
   left rule this (0,4,3) selector would otherwise override at (0,3,2). */
.w360-scroll-pinned.w360-js-pinned thead tr:first-child th.w360-section-header {
  box-shadow: inset 2px 0 0 var(--w-rule-hard), inset 0 -1px 0 var(--w-rule);
}

/* Borders must survive being made sticky: under border-collapse the shared edges
   belong to the TABLE, and a sticky th can stop painting them (engine-dependent —
   the canon measured BOTH outcomes across its adopters). box-shadow insets travel
   WITH the cell; where the host border does still paint, the same-token inset lands
   on top of it — up to DOUBLE the edge where border and inset share a width
   (review-noted: Firefox moves collapsed borders WITH sticky cells, so a pinned
   band-start seam can read ~4px against the body's 2px; accepted cosmetic, the
   same class as the canon's measured artifact; flagged to Chris's visual pass).
   Only thead cells are touched; body borders still come from the table. */
.w360-scroll-pinned.w360-js-pinned thead th.w360-band-start {
  box-shadow: inset 2px 0 0 var(--w-rule-hard);
}
.w360-scroll-pinned.w360-js-pinned thead tr:last-child th {
  box-shadow: inset 0 -1px 0 var(--w-rule);
}
.w360-scroll-pinned.w360-js-pinned thead tr:last-child th.w360-band-start {
  box-shadow: inset 2px 0 0 var(--w-rule-hard), inset 0 -1px 0 var(--w-rule);
}

/* Escape hatches (the canon's, re-derived): the CAP stays on every device — it IS
   the scrollport, and without it position:sticky has nothing to stick within (the
   canon's earlier release-the-cap approach silently disabled pinning on mobile).
   CONTAINMENT is the only thing that can trap a user — no escape gesture on touch,
   and a very short window makes a contained cage a dead zone — so containment alone
   is released. The comma is load-bearing (a disjunction; `and` would keep phones
   contained: five inescapable cages). */
@media (pointer: coarse), (max-height: 500px) {
  .w360-scroll-pinned.w360-js-contained {
    overscroll-behavior: auto;
  }
}

/* Print strips the whole treatment, including the inline `top` the JS wrote —
   no stylesheet rule beats an inline style without !important. Every gated class
   is listed: the bare container class at (0,1,0) would LOSE to the (0,2,0)
   cage/containment rules above. */
@media print {
  .w360-scroll-pinned,
  .w360-scroll-pinned.w360-js-pinned,
  .w360-scroll-pinned.w360-js-contained {
    max-height: none;
    overflow: visible;
    overscroll-behavior: auto;
  }
  .w360-scroll-pinned thead th,
  .w360-scroll-pinned.w360-js-pinned thead th,
  .w360-scroll-pinned.w360-js-pinned thead th.w360-td-sticky {
    position: static !important;
    top: auto !important;
    z-index: auto !important;
    box-shadow: none !important;
  }
}
