/* === TRACTOR — DESIGN SYSTEM ========================================= */

/* --- Fonts: SELF-HOSTED (audit A1-01 + A1-02) ------------------------------
   These used to be a <link> to fonts.googleapis.com in App.razor. That link is
   RENDER-BLOCKING and third-party, which cost two things:

     A1-01  CLS 0.28-0.57 ("poor") on four desktop pages — the fallback and the
            real face have different metrics, so every heading and chip reflowed
            when the swap landed.
     A1-02  5.5s to first paint on the offline field PWA when the font CDN was
            slow. quicklog-sw.js deliberately does not handle cross-origin
            requests, so the service worker could not precache it either — the
            one screen that must work on a bad site connection was the one
            waiting on a third party.

   Both files are the LATIN VARIABLE subsets, so ONE file per family covers every
   weight we use — that is why there is one file per family and not seven.
   Inter 48 KB (Blueprint) plus Instrument's Archivo 34.9 + Oswald 28.5 +
   JetBrains Mono 40.4, served same-origin from
   /_content/DiscoveryOne.SharedUI/fonts/ and precached in the PWA shell.
   Both are SIL Open Font License, which permits self-hosting.

   ⚠ font-display: swap is kept, so text still paints immediately in the
   fallback. What kills the LAYOUT SHIFT is the metric-overridden fallback face
   below, not the swap. */
@font-face {
    font-family: 'Inter';
    font-style: normal;
    font-weight: 100 900;              /* variable: one file, all weights */
    font-display: swap;
    src: url('fonts/inter-latin-var.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;
}

/* --- INSTRUMENT's three faces ---------------------------------------------
   Oswald silkscreen legends, Archivo labels and body, JetBrains Mono readouts.
   All three are LATIN VARIABLE subsets, so one file covers every weight — the same
   reason Inter is one file and not seven.

   ⚠ THE WHOLE STACK IS SMALLER THAN WHAT IT REPLACES, because Caveat leaves with it:
   Inter 48 KB + Caveat 75 KB = 123 KB becomes Archivo 34.9 + Oswald 28.5 +
   JetBrains Mono 40.4 = 103.8 KB. Caveat was 75 KB precached in BOTH field PWAs'
   offline shells to serve `.hand`, which had zero consumers anywhere in the repo —
   no class attribute in any .razor/.html/.cshtml, no "hand" string in C#, nothing in
   any PDF renderer. Re-checked immediately before deleting rather than trusted from
   the earlier count.

   ⚠ font-display: swap is kept for the same reason it is kept on Inter: text paints
   immediately in the fallback. What removes the LAYOUT SHIFT is the metric-overridden
   fallback face below, not the swap. */
@font-face {
    font-family: 'Archivo';
    font-style: normal;
    font-weight: 100 900;
    font-display: swap;
    src: url('fonts/archivo-latin-var.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: 'Oswald';
    font-style: normal;
    font-weight: 200 700;
    font-display: swap;
    src: url('fonts/oswald-latin-var.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: 'JetBrains Mono';
    font-style: normal;
    font-weight: 100 800;
    font-display: swap;
    src: url('fonts/jetbrainsmono-latin-var.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;
}

/*  Instrument's twin of 'Inter Fallback', and the numbers are MEASURED against Archivo
    rather than copied from Inter's — that is the whole point of it existing separately.
    Taken in the browser at 32px against the real self-hosted file
    (tools/e2e/instrument-fontmetrics.spec.js re-runs the measurement):

        Archivo   @32px : 35.00px tall, 641.22px wide   asc 88.00%  desc 21.00%
        Segoe UI  @32px : 43.00px tall, 640.38px wide   asc 108.00% desc 25.00%

    ⚠ THE DELTA IS BIGGER HERE THAN IT WAS FOR INTER, WHICH IS THE OPPOSITE OF WHAT
    "the widths nearly match" SUGGESTS. Archivo and Segoe UI are 0.13% apart in width,
    so size-adjust is almost a no-op — but the fallback line box is EIGHT pixels taller
    at 32px against Inter's four. Skipping this face because the widths looked fine
    would have doubled the layout shift the Inter work removed.

    ⚠ size-adjust scales the em, so ascent/descent are divided by the same factor
    (88.00/1.0013, 21.00/1.0013) or fixing the width re-breaks the height.
    ⚠ Tuned against Segoe UI, the Windows fallback — same limitation as 'Inter Fallback'. */
@font-face {
    font-family: 'Archivo Fallback';
    src: local('Segoe UI'), local('Helvetica Neue'), local('Arial'), local('Roboto');
    size-adjust: 100.13%;
    ascent-override: 87.88%;
    descent-override: 20.97%;
    line-gap-override: 0%;
}

/* ⚠⚠ THIS is the A1-01 fix, and it is the part that is easy to leave out.
   Self-hosting removes the network stall; it does NOT remove the layout shift,
   because the shift comes from the FALLBACK having different metrics than Inter.
   'Inter Fallback' re-describes the local system font with Inter's own metrics
   (ascent/descent/line-gap as a % of em, taken from the Inter release), so the
   line box is already the right height before the real face arrives and the swap
   moves nothing. It is referenced FIRST-after-Inter in --font-sans below.

   ⚠ THESE FOUR NUMBERS ARE MEASURED, NOT COPIED. Taken in the browser against the
   real self-hosted file at 32px, comparing the rendered line box of the same string:

       raw system fallback : 43.00px tall, 541.25px wide   -> 4px TALLER than Inter
       ascent/descent only : 39.00px tall, 541.25px wide   -> height fixed, width off
       + size-adjust       : 39.00px tall, 579.70px wide   -> Inter is 39.00 / 579.52

   i.e. height delta 4px -> 0px, width delta 38px -> 0.18px. The 4px-per-line
   vertical delta is what the CLS actually was.
   ⚠ size-adjust scales the em, so ascent/descent MUST be divided by the same factor
   (96.88/1.0707, 24.14/1.0707) or fixing the width re-breaks the height.
   ⚠ Tuned against Segoe UI, the Windows fallback. A Mac resolves local() to
   Helvetica Neue, whose metrics differ — the override still helps there but is not
   exact. One override per fallback font is the limit of this technique. */
@font-face {
    font-family: 'Inter Fallback';
    src: local('Segoe UI'), local('Helvetica Neue'), local('Arial'), local('Roboto');
    size-adjust: 107.07%;
    ascent-override: 90.48%;
    descent-override: 22.55%;
    line-gap-override: 0%;
}

/* The UA rule is `[hidden] { display: none }`, which ANY author `display`
   declaration outranks — so an element that is both `hidden` and styled
   `display: flex` (e.g. #ql-outbox on /quicklog) stays visible. Restore the
   attribute's meaning app-wide. (B6-03) */
[hidden] { display: none !important; }

/* --- CSS Variables --------------------------------------------------- */
:root {
    /* Tell the UA which way this page is painted so native chrome — select
       popups, caret, selection handles, autofill highlight, scrollbars — matches
       the theme instead of always rendering light. Paired with the dark override
       on [data-theme="dark"] below; both must move together. */
    color-scheme: light;

    /* ========================================================================
       THE PALETTE — Andre's five, 2026-07-22. These are the only source colors
       in the app; every token below derives from them (plus the five semantic
       status hues, which are meaning, not brand). Blueprint's swatch strip:
       Design/blueprint-mockup.html lines 392-397.

         Ink      #1C1917   warm near-black — text, and the one solid dark fill
         Paper    #FAF9F7   warm white — the content canvas
         Surface  #F5F4F0   a step darker — chrome, rail, wells, headers
         Brand    #2B6BDB   Discovery ONE blue — the SINGLE primary action + focus
         AI draft #5D46A4   violet — anything the machine wrote or suggests

       ⚠ The accent was terracotta #A6431C ("redline", a plan-room metaphor)
       until the Discovery ONE rebrand. It is now the blue taken from the logo
       gradient's light end, so the one accent in the product matches the mark.
       --redline survives as an ALIAS of --brand: it is spelled out in ~110
       places via --sky/--accent, and renaming those buys nothing. New code
       should say var(--brand).

       ⚠ The blue is NOT the wordmark blue #2F7BF6. That measures 4.0:1 on white
       and would fail AA for normal text, which this token has to pass — --sky is
       link colour (.boards-link and friends), not just a button fill. #2B6BDB is
       the same hue off the gradient at 4.97:1. Keep any future change above 4.5:1
       on --card, and above 4.5:1 on --paper in dark.

       White (#FFF) is not a brand color; it is the sheet a card is printed on,
       so it lives as --card and never as an accent. Every grey that used to be
       hand-picked (#EBE8E2, #3a3a38, …) is now an ink tint or one of the five.
       ======================================================================== */
    --ink:        #1C1917;
    --paper:      #FAF9F7;
    --surface:    #F5F4F0;
    --brand:      #2B6BDB;   /* Discovery ONE blue — 4.97:1 on --card */
    --redline:    var(--brand);   /* pre-rebrand name, kept so ~110 call sites hold */
    --ai:         #5D46A4;

    /* Floating rail geometry. --rail-shut is the RESTING width, which is why it
       is the one the shell reserves space for; --rail-open is the hover/pinned
       width. See the ≥1025px block near .navrail-backdrop. */
    --rail-shut:  55px;
    --rail-open:  190px;
    --rail-gap:   10px;

    /* Rail motion, in one place so it can be tuned without hunting five rules.
       ⚠ Halved on 2026-08-05 (was 170/110/260ms) — at the original timing the
       rail took ~280ms from touch to fully open, which reads as the panel
       thinking about it rather than responding.
       The IN and OUT delays stay ASYMMETRIC and that is deliberate: a delay on
       the way in stops the rail twitching when the pointer is merely crossing
       it, and a longer one on the way out forgives a two-pixel overshoot.
       ⚠⚠ --rail-in NOW GATES THE WHOLE OPEN, not just the width. It used to gate
       the width alone, so raising it did not make the rail calmer — it made the
       rail LATE: the labels, the count pills, the submenu and the row centring
       all still flipped on the first hovered frame, and then stood in a 68px
       panel for the whole delay waiting for it to catch up. Everything the shut
       state changes is on this variable now (see the ≥1025px block), which is
       what makes it safe to tune: at 0.55s the rail waits half a second and then
       opens in one motion, instead of half-opening instantly. */
    --rail-dur:   0.085s;
    --rail-in:    0.55s;
    --rail-out:   0.13s;
    --rail-ease:  cubic-bezier(0.22, 0.61, 0.36, 1);
    --card:       #FFFFFF;   /* the printed sheet — cards, tables, the top bar */

    /* --- Derived: brand aliases the stylesheet already speaks in ----------- */
    --accent:      var(--brand);
    --accent-dn:   #1F55B4;                    /* brand blue, pressed — 6.97:1 */
    --accent-tint: rgba(43, 107, 219, 0.08);

    --sky:        var(--brand);     /* legacy accent name: active, focus, checkboxes */
    --sky-light:  rgba(43, 107, 219, 0.10);
    --sky-soft:   rgba(43, 107, 219, 0.05);
    --navy:       var(--ink);       /* legacy ink name (text + primary fills) */
    --navy-soft:  #57534E;          /* the ONE approved muted grey (status grey) */
    --ink3:       #6E6862;          /* micro-labels only — ink lightened just far
                                       enough to stay AA at 10.5px on Surface */
    --deep:       var(--ink);       /* solid dark fills are INK, not a stray grey */

    /* --- Derived: surfaces ------------------------------------------------ */
    --bg:         var(--paper);     /* content canvas */
    --paper-warm: var(--surface);   /* chrome / sidebar / wells */
    --paper-cool: var(--paper);     /* recessed fields on white chrome */
    --fill:       rgba(28, 25, 23, 0.07);   /* ink tint: chips, tracks, hovers */
    --fill-soft:  var(--surface);
    --input-bg:   var(--card);

    /* Lines — Blueprint hairlines (ink at low opacity, so they sit on any surface) */
    --line:       rgba(28, 25, 23, 0.12);
    --line-mid:   rgba(28, 25, 23, 0.24);
    --line-soft:  rgba(28, 25, 23, 0.065);
    /* Functional glyphs (search icon, section caret, breadcrumb separators).
       ⚠ These are drawn as TEXT CHARACTERS (⌕, ▾, ↕, /), not as SVG, so a browser
       and an auditing tool both measure them against the 4.5:1 TEXT threshold, not
       the 3:1 non-text one — regardless of what they mean semantically. At the old
       0.48 they measured 3.16:1 on --card, 3.12:1 on --paper and 3.09:1 on
       --surface: over the non-text bar, under the text bar, and genuinely faint.
       0.62 clears 4.5:1 on ALL THREE light surfaces (4.90 / 4.81 / 4.74) and reads
       6.46:1 in dark. The minimum that clears every surface is 0.605; 0.62 keeps a
       margin so a future surface tweak cannot silently drop it back under.
       --line-mid is a BORDER token at 1.67:1 — never use it for a glyph. */
    --glyph-ui:   rgba(28, 25, 23, 0.62);
    --grid-line:  rgba(28, 25, 23, 0.035);   /* the drafting-grid paper texture */

    /* Muted text — Blueprint's one approved grey, AA-readable on paper */
    --text-muted: #57534E;

    /* Chart hues — data visuals never use the redline accent; Blueprint's
       blue ink alongside the AI violet and the ok/warn/bad status hues. */
    --chart-blue:   #1E5A8A;
    --chart-purple: var(--ai);

    /* Status text inks — Blueprint's six-role status vocabulary: dark ink on a
       same-hue ~11% tint, AA on the tints and on paper. Fills, borders and
       progress bars use the same inks. */
    --ok-ink:   #1B6B45;
    --warn-ink: #7C500A;
    --bad-ink:  #B3261E;

    /* Status — ink hues + translucent same-hue tints (work on any surface) */
    --ok:         #1B6B45;
    --ok-light:   rgba(27, 107, 69, 0.11);
    --warn:       #B27816;
    --warn-light: rgba(178, 120, 22, 0.14);
    --bad:        #B3261E;
    --bad-light:  rgba(179, 38, 30, 0.09);

    /* Row tags */
    --row-display:  rgba(30, 90, 138, 0.10);
    --row-reorder:  rgba(179, 38, 30, 0.09);
    --row-both:     rgba(178, 120, 22, 0.14);
    --row-overdue:  rgba(179, 38, 30, 0.09);
    --row-employee: rgba(30, 90, 138, 0.10);

    /* Pipeline stage pills / kanban headers — Blueprint's blue/violet ink+tint
       pairs (contrast-checked in both themes) */
    --stage-estimate-fg:    #8A5B0E;   /* on --warn-light */
    --stage-materials-bg:   var(--ai-tint);
    --stage-materials-fg:   var(--ai);
    --stage-inprogress-bg:  rgba(30, 90, 138, 0.11);
    --stage-inprogress-fg:  #1E5A8A;
    --stage-walkthrough-bg: var(--ai-tint);
    --stage-walkthrough-fg: var(--ai);

    /* AI draft's own tint — every "the machine wrote this" surface uses it. */
    --ai-tint: rgba(93, 70, 164, 0.10);

    /* Borders / Shadows — Blueprint: depth comes from hairlines; the soft
       shadow is a whisper for cards, the md pair is reserved for true
       overlays (modals, dropdowns, popovers). */
    --shadow-soft: 0 1px 2px rgba(28,25,23,0.05), 0 4px 16px rgba(28,25,23,0.04);
    --shadow-md:   0 8px 24px rgba(28,25,23,0.14), 0 24px 60px -24px rgba(28,25,23,0.28);

    /* Radii */
    --r-pill: 999px;
    --r:      12px;
    --r-sm:   8px;
    --r-xs:   5px;

    /* Spacing — a 4px rhythm, added 2026-08-15.
       ⚠ WHY THIS EXISTS: until now there was no spacing token at all. A sweep of
       the rendered product measured 528 literal-px padding/margin/gap declarations
       across 34 DISTINCT values (2,3,4,5,6,7,8,9,10,11,12,13,14,16,20,24,28…) —
       essentially every integer, which is what "no scale" looks like from the
       outside: 20.5 distinct spacing values on an average page.
       These tokens are ADDITIVE and change nothing on their own. Nothing is
       retrofitted onto them in this pass, deliberately — rewriting 528 live
       declarations is churn with real regression risk and no user-visible gain.
       Use them for NEW rules, and adopt them opportunistically when a block is
       being edited for another reason. The values are chosen to absorb the
       existing clusters: 8px (x121), 6px (x82), 10px (x80), 12px (x67), 4px (x58). */
    --space-1:  4px;
    --space-2:  8px;
    --space-3: 12px;
    --space-4: 16px;
    --space-5: 24px;
    --space-6: 32px;
    --space-7: 48px;

    /* Type */
    /* 'Inter Fallback' sits directly after Inter so the metric-overridden face is
       what renders during the swap window — putting system-ui first would restore
       the layout shift the override exists to prevent. */
    --font-sans: 'Inter', 'Inter Fallback', system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
    --font-mono: ui-monospace, 'Cascadia Mono', Consolas, 'SF Mono', Menlo, monospace;

    /* Type scale — one step up from the legacy 9/10/11/12 ramp for readability
       (especially outdoors on phones). Emoji/caret/avatar glyph sizes stay raw
       px on purpose; only text uses these tokens. Values are rem so text
       follows the user's browser font-size preference (px equivalents in the
       comments assume the 16px default). */
    /* ⚠⚠ THE NAMES DO NOT ORDER BY SIZE, AND THAT HAS BITTEN READERS. Sorted by
       actual value the ladder is:
           2xs 11  <  xs 12  <  sm 13  <  md 15  <  base 16 == lg 16  <  h2 22 == kpi 22
       So --fs-md is SMALLER than --fs-base, --fs-lg is IDENTICAL to --fs-base, and
       --fs-h2 is IDENTICAL to --fs-kpi. Picking "the next size up from base" by
       reading the names gets you a smaller font. They are NOT renamed here on
       purpose: 192 call sites resolve these, and a rename is a large diff whose
       only benefit is the diff itself. Read the px comment, never the suffix.
       ⚠ There is no rung above 22px, which is why 25/26/30/32/38/40px appear as
       literals elsewhere in this file. A display tier is the natural next token. */
    /* ⚠ --fs-2xs IS THE MICRO-LABEL FLOOR. Nothing a user READS may go below it.
       2026-08-15: eleven selectors sat under it as raw literals — eight at 10.5px
       (.kpi-label, table.tbl thead th, .section-header, .fh-cap, .bp-caption,
       .bp-hero-lbl, .bp-doc-h, .ln-band-label) and three at 9.5px (.slv-ai,
       .bp-spine-lb, .ln-band-cost). All eleven are mono UPPERCASE LETTERSPACED
       text, which is the least legible combination there is at small sizes, and a
       phone sweep counted 1,922 sub-12px text nodes — on the device a crew reads
       outdoors. They now resolve to this token.
       ⚠ GLYPHS ARE NOT TEXT AND ARE DELIBERATELY EXEMPT: .avatar / .mobilenav-avatar
       (an initial inside a 22-24px circle) and .accordion-toggle (a caret) stay at
       raw 9px. Raising those changes an icon's proportions, not anyone's reading. */
    --fs-2xs:  0.6875rem;  /* 11px — pills, .lbl, .tiny, table th, field labels */
    --fs-xs:   0.75rem;    /* 12px — buttons, .mono, hints, kanban headers */
    --fs-sm:   0.8125rem;  /* 13px — table cells, inputs, nav, secondary text */
    --fs-base: 1rem;       /* 16px — body (Blueprint: 16/1.5; labels/tables keep their smalls) */
    --fs-md:   0.9375rem;  /* 15px — h3, modal titles, card titles */
    --fs-lg:   1rem;       /* 16px — reconnect title, larger callouts */
    --fs-h2:   1.375rem;   /* 22px — page titles (matches the original app) */
    --fs-kpi:  1.375rem;   /* 22px — KPI values */

    /* Top bar */
    --topbar-h: 56px;      /* mockup .topbar */
    --actionbar-h: 38px;
    --strip-h: 72px;
}

/* --- Dark theme ------------------------------------------------------- */
/* Variables are redefined on the themed wrapper (html[data-theme]), so the
   whole subtree re-colours just by toggling the attribute. Warm-toned darks
   with a darker --bg behind lighter --paper surfaces for tile contrast. */
[data-theme="dark"] {
    /* Native UA chrome follows the theme — see the light counterpart on :root. */
    color-scheme: dark;

    /* Blueprint dark: the SAME five roles, re-tuned for a stone-dark ground.
       Ink is the warm off-white you read; Paper/Surface/Card keep their
       order (canvas darkest → card lightest) so every rule written against
       the light palette still means the same thing here. */
    --ink:        #f0ede8;   /* text ink, inverted */
    --paper:      #191917;   /* canvas */
    --surface:    #2a2a26;   /* chrome / rail / wells */
    --card:       #22221f;   /* the printed sheet, raised above the canvas */
    --brand:      #5B9CFF;   /* brand blue, lightened for the dark ground — 6.43:1
                                on --paper. The light-theme #2B6BDB measures only
                                2.7:1 here, so it cannot simply carry over. */
    --redline:    var(--brand);
    --ai:         #cbb3e8;   /* AI violet, lightened */
    --ai-tint:    #372a44;

    --accent:      var(--brand);
    --accent-dn:   #4785E6;
    --accent-tint: rgba(91, 156, 255, 0.14);

    --sky:        var(--brand);
    --sky-light:  rgba(91, 156, 255, 0.14);
    --sky-soft:   rgba(91, 156, 255, 0.08);
    --navy:       var(--ink);
    --navy-soft:  #a0a09a;   /* secondary text (warm grey) */
    --ink3:       #9d9d96;   /* micro-labels only (dark-tuned to clear AA on
                                --surface and --fill too) */
    --deep:       #4a4a46;   /* raised neutral that still reads with white text */

    --bg:         var(--paper);
    --paper-warm: var(--surface);
    --paper-cool: #1e1e1b;   /* recessed field, one step below the card */
    --fill:       rgba(240, 237, 232, 0.09);
    --fill-soft:  #2c2c28;
    --input-bg:   #1a1a18;   /* recessed field surface, sits below the cards */

    --line:       #3a3a36;
    --line-mid:   #4a4a46;
    --line-soft:  #333330;
    --glyph-ui:   #7a7a74;   /* functional glyphs, >=3:1 on --card (see light) */
    --grid-line:  rgba(240, 237, 232, 0.03);   /* drafting grid, dark paper */

    --text-muted: #aeaea8;   /* warm grey, AA on the dark card AND on the
                                tinted .nav-ct / pill backgrounds composited
                                over it (B6-09 measured #a0a09a at 4.11:1) */

    --chart-blue:   #85B7EB;
    --chart-purple: var(--ai);

    /* In dark mode the base status hues are already light enough to read. */
    --ok-ink:   #5DCAA5;
    --warn-ink: #EF9F27;
    --bad-ink:  #F09595;

    --ok:         #5DCAA5;
    --ok-light:   #10362c;
    --warn:       #EF9F27;
    --warn-light: #3d2a10;
    --bad:        #F09595;
    --bad-light:  #43201d;

    --row-display:  #253647;
    --row-reorder:  #43201d;
    --row-both:     #3d2f13;
    --row-overdue:  #43201d;
    --row-employee: #253647;

    --stage-estimate-fg:    #e6bd70;   /* = --warn, on the dark --warn-light */
    --stage-materials-bg:   var(--ai-tint);
    --stage-materials-fg:   var(--ai);
    --stage-inprogress-bg:  #3e2a1e;
    --stage-inprogress-fg:  #edaf8c;
    --stage-walkthrough-bg: var(--ai-tint);
    --stage-walkthrough-fg: var(--ai);

    --shadow-soft: 0 1px 2px rgba(0,0,0,0.45), 0 2px 6px rgba(0,0,0,0.40);
    --shadow-md:   0 2px 10px rgba(0,0,0,0.55), 0 6px 20px rgba(0,0,0,0.45);
}

/* Recess the page background so the --paper tiles/cards stand out (both
   themes — the original app's white-cards-on-warm-grey look). List-page
   scroll areas are transparent, so colouring .app-shell covers them; .dash
   sets --paper explicitly, so it needs the override too. */
.app-shell,
.dash {
    background-color: var(--bg);
    /* The Blueprint signature: faint 32px drafting-grid paper behind the
       cards — quiet enough to disappear behind content, unmistakable in the
       gutters. Both themes define --grid-line. */
    background-image:
        linear-gradient(var(--grid-line) 1px, transparent 1px),
        linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
    background-size: 32px 32px;
}

/* A few components use --navy as a solid background; remap them in dark so
   their white text stays legible. */
[data-theme="dark"] .btn-primary           { background: var(--accent);    border-color: var(--accent);    color: #1C1917; }
[data-theme="dark"] .btn-primary:hover      { background: var(--accent-dn); border-color: var(--accent-dn); color: #1C1917; }
[data-theme="dark"] .pill-navy              { background: var(--deep); color: #fff; }
[data-theme="dark"] .accordion-header.open  { background: var(--deep); color: #fff; }
[data-theme="dark"] .flash-ok               { color: var(--ok);   border-color: #2f5235; }
[data-theme="dark"] .flash-warn             { color: var(--warn); border-color: #5a4a22; }
[data-theme="dark"] .toast-ok               { color: var(--ok);   border-color: #2f5235; }
[data-theme="dark"] .toast-warn             { color: var(--warn); border-color: #5a4a22; }
[data-theme="dark"] .toast-bad              { color: var(--bad);  border-color: #5a2f24; }

/* …and the same for white-on-status text: --ok/--bad/--warn-ink/--sky flip to
   LIGHT hues in dark mode, so anything that paints white text over them needs
   dark ink instead. */
[data-theme="dark"] .viewing-as-badge:hover    { color: #1a1a18; }
[data-theme="dark"] .stepper-circle.done,
[data-theme="dark"] .stepper-circle.current,
[data-theme="dark"] .order-step-circle.done,
[data-theme="dark"] .order-step-circle.current { color: #1a1a18; }
[data-theme="dark"] .btn-danger                { color: #1a1a18; }
[data-theme="dark"] .btn-danger:hover          { background: #e57f7f; border-color: #e57f7f; color: #1a1a18; }
[data-theme="dark"] .role-chip-x:hover         { color: #1a1a18; }
/* The theme toggle's white knob sits on the light --sky track when on. */
[data-theme="dark"] .theme-toggle.on .theme-toggle-knob { background: #1a1a18; }
/* The ink spinner's faint navy ring vanishes on dark surfaces. */
[data-theme="dark"] .spinner-ink { border-color: rgba(240, 237, 232, 0.22); border-top-color: var(--navy); }

/* --- INSTRUMENT ------------------------------------------------------- */
/*  The app as an instrument cluster, not a document. See INSTRUMENT-DESIGN-SPEC.md.

    ⚠⚠ THIS IS A THIRD SHAPE, NOT A THIRD PALETTE. Blueprint light and Blueprint dark
    both paint the canvas and the cards the SAME way up: canvas darkest, card lightest,
    ink opposite. Instrument inverts only ONE of those. The chassis — canvas, rail, top
    bar — is graphite in BOTH faces and never moves; the FACES, meaning tables, cards and
    panels, are bone by day and dark by night. That is what an instrument cluster does:
    one housing, a day face and a night face.

    ⚠ CONSEQUENCE, AND IT IS THE ONE THAT BREAKS THINGS: anything drawing --ink text
    directly on the CANVAS rather than on a card is dark-on-dark here, because --bg is
    graphite while --card is bone. Those sites move to --bd1/--bd2/--bd3, the text tiers
    for a dark surface. tools/e2e/ux-audit.spec.js names every one of them.

    ⚠⚠ COLOUR IS A LEGAL CODE AND ONLY THREE WORDS ARE SPELLED IN IT:
        --warn  #EF6C0B  ANSI safety orange  =  OUT OF TOLERANCE
        --ok    green                        =  COMPLETE
        --bad   red                          =  DANGER
    Everything else earns its distinction from weight, rule and position. The AI violet,
    the four stage-pill pairs, the five row tags and the two chart hues all collapse to
    graphite/bone/ink on purpose — a code with six words is not a code.

    ⚠⚠ AMBER IS POSITIONAL, WHICH IS WHY --warn AND --warn-ink DIVERGE BY DAY.
    #EF6C0B measures 5.16:1 on the bay, which is fine as text, and 2.66:1 on bone, which
    fails AA at any size and fails the 3:1 graphical bar too. The comp itself does this:
    its .dnum.hot is 12px weight-700 amber on the bone table face. So:
        --warn      stays EXACTLY #EF6C0B, and on a bone face it may only be a FILL, a
                    BAR or a RULE — carrying a 1px ink edge, since it is under 3:1.
        --warn-ink  is the TEXT token, and on bone it is #A34C06 at 5.05:1 — amber's
                    readable relative, not the code colour.
    Darkening --warn itself was rejected: it clears AA and stops being the colour that
    was chosen. Keeping the two equal was rejected: it ships illegible text.

    ⚠ Every ratio in the comments below was computed against these exact values, not
    copied from the comp. Six of the comp's own values did not survive AA.               */

[data-theme="instrument-day"] {
    /*  ⚠⚠ THE DAY FACE IS A REAL LIGHT THEME, AND IT DID NOT START THAT WAY.
        The first cut held the chassis graphite in BOTH faces — "one housing, a day face
        and a night face" — which is a coherent idea and a wrong product. It meant the
        light mode was a dark app wearing light cards: a large graphite canvas with a few
        bone panels floating on it. On a mostly-empty page like /followups that is almost
        entirely dark void. THERE WAS EFFECTIVELY NO LIGHT MODE. So the housing lights up
        too: by day the whole cluster is lit, by night it is dimmed.

        ⚠ Only the surface values changed. Wave 2 routes ink by SURFACE rather than by
        literal, so the architecture carried this without edits — the surfaces simply
        moved and the tiers inverted with them.

        ⚠ The instrument character is carried by GEOMETRY, TYPE and the COLOUR CODE —
        zero radius, Oswald legends, mono readouts, three colours and nothing else — not
        by being dark. That is what keeps a lit chassis from collapsing back into
        Blueprint. */
    color-scheme: light;

    /* --- The chassis, lit. Same roles, same order, light values. ------------ */
    --chassis:  #E7E9E4;   /* deepest reveal — the gap BETWEEN bays */
    --bay:      #EDEFEC;   /* bay face — rail, top bar, wells */
    --key:      #E2E5DF;   /* unlit key */
    --key-hi:   #D8DBD4;   /* lit key */

    /*  Text tiers for anything sitting ON the chassis. They INVERT with the face, which
        is the whole reason wave 2 routed chassis text through tokens instead of literals.
        Worst of the three is 4.84:1, on an unlit key. */
    --bd1:      #101314;   /* primary   — 15.26 on the chassis, 18.66 on a card */
    --bd2:      #454C4D;   /* secondary — 7.17 on the chassis */
    --bd3:      #5C6360;   /* micro-label floor — 5.04 on the chassis, 4.84 on a key */

    /* Bevels invert too: the highlight is above, the shadow below. */
    --edge-hi:  rgba(255, 255, 255, 0.90);
    --edge-lo:  rgba(16, 19, 20, 0.12);

    /* --- The five roles ---------------------------------------------------- */
    --ink:        #101314;   /* 18.66:1 on a white card */
    --paper:      #E7E9E4;   /* canvas = the chassis reveal */
    --surface:    #EDEFEC;   /* chrome / rail / wells = the bay */
    --card:       #FFFFFF;   /* THE INSTRUMENT FACE. White. */

    /*  ⚠ Still no brand hue. Emphasis is a machined key (see --key-fill), and links are
        distinguished by rule and weight rather than by colour. */
    --brand:      #101314;
    --redline:    var(--brand);

    --ai:         #454C4D;
    --ai-tint:    rgba(16, 19, 20, 0.05);

    --accent:      var(--brand);
    --accent-dn:   #000000;
    --accent-tint: rgba(16, 19, 20, 0.07);

    --sky:        var(--brand);
    --sky-light:  rgba(16, 19, 20, 0.09);
    --sky-soft:   rgba(16, 19, 20, 0.045);
    --navy:       var(--ink);
    --navy-soft:  #454C4D;   /* 8.77:1 on a card */
    --ink3:       #5C6360;   /* micro-label floor — 6.16 on a card, 4.84 worst case */
    --deep:       #1E2324;   /* the one solid dark fill: a key */

    --bg:         var(--paper);
    --paper-warm: var(--surface);
    --paper-cool: #F3F4F1;   /* recessed field on a white card */
    --fill:       rgba(16, 19, 20, 0.06);
    --fill-soft:  #F3F4F1;
    --input-bg:   #F3F4F1;

    --line:       #DCDFD9;
    --line-mid:   #B4BAB2;
    --line-soft:  #E7E9E4;
    --glyph-ui:   #454C4D;   /* functional glyphs are TEXT characters — 8.77:1 */
    /* ⚠ The drafting grid inverts BACK: a light canvas needs a dark tint. */
    --grid-line:  rgba(16, 19, 20, 0.035);

    --text-muted: #454C4D;

    --chart-blue:   #454C4D;
    --chart-purple: #6E7671;

    /*  The tiers for text on a FACE. Identical to the chassis tiers now that both are
        light — kept as separate tokens anyway so wave 2's face-reset keeps working
        unchanged and the night face can still differ. */
    --face-ink:  #101314;
    --face-ink2: #454C4D;
    --face-ink3: #5C6360;

    /*  ⚠ THE KEY FILL INVERTS AGAINST THE PANEL, NOT WITH THE THEME. A machined key is
        the one solid dark object on a lit panel — which is what a primary button, an
        avatar and a chat bubble each are. */
    --key-fill:     #1E2324;
    --key-fill-ink: #FFFFFF;   /* 15.90:1 */

    /*  ⚠ THE TEXT HALF OF THE CODE, AND THE POSITIONAL RULE IS UNCHANGED BY THE RELIGHT.
        Amber still cannot be text on a light surface: #EF6C0B measures 3.08:1 on white
        and 2.52 on the chassis. If anything the lit chassis is the WORSE surface for it,
        not the better one. So --warn-ink stays amber's readable relative and --warn stays
        the untouched ANSI orange for fills, bars and rules. */
    --ok-ink:   #0B7040;   /* 6.17 on a card, 4.85 worst case */
    --warn-ink: #A34C06;   /* 5.84 on a card, 4.59 worst case */
    --bad-ink:  #B01026;   /* 7.12 on a card, 5.60 worst case */

    --ok:         #0B7040;
    --ok-light:   rgba(11, 112, 64, 0.11);
    --warn:       #EF6C0B;
    --warn-light: rgba(239, 108, 11, 0.13);
    --bad:        #B01026;
    --bad-light:  rgba(176, 16, 38, 0.09);

    --row-display:  rgba(16, 19, 20, 0.045);
    --row-reorder:  rgba(176, 16, 38, 0.08);
    --row-both:     rgba(239, 108, 11, 0.12);
    --row-overdue:  rgba(239, 108, 11, 0.12);
    --row-employee: rgba(16, 19, 20, 0.045);

    --stage-estimate-fg:    #A34C06;
    --stage-materials-bg:   rgba(16, 19, 20, 0.05);
    --stage-materials-fg:   #454C4D;
    --stage-inprogress-bg:  rgba(16, 19, 20, 0.05);
    --stage-inprogress-fg:  #101314;
    --stage-walkthrough-bg: rgba(16, 19, 20, 0.05);
    --stage-walkthrough-fg: #454C4D;

    /* Depth is still a bevel, not a blur — the edges just changed sign. */
    --shadow-soft: 0 1px 0 var(--edge-lo), inset 0 1px 0 var(--edge-hi);
    --shadow-md:   0 8px 24px rgba(16,19,20,0.14), 0 24px 60px -24px rgba(16,19,20,0.26);
}


/*  The night face. The chassis does not move; the faces darken and the ink inverts.

    ⚠⚠ THE FACE SITS ONLY 1.11:1 OFF THE BAY, AND THAT IS DELIBERATE. A face light
    enough to separate from the housing BY FILL pushes amber under AA — measured:
    #2A3132 gives a 1.20 separation and drops --warn to 4.31, #2D3435 gives 1.25 and
    drops it to 4.12. Amber is the one colour this system does not bend, so the face
    stays dark and the BEVEL draws the boundary instead. That makes --edge-hi/--edge-lo
    load-bearing here in a way they are not by day.                                     */
[data-theme="instrument-night"] {
    color-scheme: dark;

    --chassis:  #15191A;
    --bay:      #1E2324;
    --key:      #262B2C;
    --key-hi:   #2E3435;

    --bd1:      #C1C4C2;
    --bd2:      #A1A5A3;
    --bd3:      #8D9190;

    --face-ink:  #C1C4C2;
    --face-ink2: #A1A5A3;
    --face-ink3: #8D9190;

    --key-fill:     #C1C4C2;
    --key-fill-ink: #101314;

    --edge-hi:  rgba(255, 255, 255, 0.075);
    --edge-lo:  rgba(0, 0, 0, 0.55);

    --ink:        #C1C4C2;   /* primary text on the night face — 8.18:1 */
    --paper:      #15191A;
    --surface:    #1E2324;
    --card:       #252B2C;   /* THE NIGHT FACE */

    --brand:      #C1C4C2;   /* emphasis inverts with the face: bone on graphite */
    --redline:    var(--brand);

    --ai:         #A1A5A3;
    --ai-tint:    rgba(237, 239, 236, 0.06);

    --accent:      var(--brand);
    --accent-dn:   #D8DBD9;
    --accent-tint: rgba(237, 239, 236, 0.10);

    --sky:        var(--brand);
    --sky-light:  rgba(237, 239, 236, 0.10);
    --sky-soft:   rgba(237, 239, 236, 0.05);
    --navy:       var(--ink);
    --navy-soft:  #A1A5A3;   /* 5.77:1 on the face */
    --ink3:       #8D9190;   /* micro-label floor — 4.51:1 on the face */
    --deep:       #2E3435;

    --bg:         var(--paper);
    --paper-warm: var(--surface);
    --paper-cool: #1A1F20;   /* recessed field, one step BELOW the night face */
    --fill:       rgba(237, 239, 236, 0.08);
    --fill-soft:  #1A1F20;
    --input-bg:   #1A1F20;

    --line:       #555A5A;
    --line-mid:   #6C7271;
    --line-soft:  #3A4041;
    --glyph-ui:   #A1A5A3;
    --grid-line:  rgba(237, 239, 236, 0.030);

    --text-muted: #A1A5A3;

    --chart-blue:   #A1A5A3;
    --chart-purple: #8D9190;

    /* ⚠ At night amber CAN be text — 4.67:1 on the face. So --warn-ink and --warn
       converge here, and the divergence by day is a property of bone, not of the code. */
    --ok-ink:   #35B473;   /* 5.43:1 on the face */
    --warn-ink: #EF6C0B;   /* 4.67:1 on the face */
    --bad-ink:  #FF7A6E;   /* 5.66:1 on the face */

    --ok:         #35B473;
    --ok-light:   rgba(53, 180, 115, 0.14);
    --warn:       #EF6C0B;
    --warn-light: rgba(239, 108, 11, 0.16);
    --bad:        #FF7A6E;
    --bad-light:  rgba(255, 122, 110, 0.14);

    --row-display:  rgba(237, 239, 236, 0.05);
    --row-reorder:  rgba(255, 122, 110, 0.12);
    --row-both:     rgba(239, 108, 11, 0.15);
    --row-overdue:  rgba(239, 108, 11, 0.15);
    --row-employee: rgba(237, 239, 236, 0.05);

    --stage-estimate-fg:    #EF6C0B;
    --stage-materials-bg:   rgba(237, 239, 236, 0.06);
    --stage-materials-fg:   #A1A5A3;
    --stage-inprogress-bg:  rgba(237, 239, 236, 0.06);
    --stage-inprogress-fg:  #C1C4C2;
    --stage-walkthrough-bg: rgba(237, 239, 236, 0.06);
    --stage-walkthrough-fg: #A1A5A3;

    --shadow-soft: 0 1px 0 var(--edge-lo), inset 0 1px 0 var(--edge-hi);
    --shadow-md:   0 2px 10px rgba(0,0,0,0.65), 0 6px 20px rgba(0,0,0,0.55);
}

/*  Component twins of the [data-theme="dark"] overrides above. Each exists for the same
    reason its dark counterpart does: a component painting text over a token whose
    LIGHTNESS has flipped needs the opposite ink.

    ⚠⚠ THE TWO FACES NEED OPPOSITE TWINS, WHICH IS EASY TO GET WRONG BY COPYING. By DAY
    --accent is dark ink, which is the LIGHT theme's situation, so .btn-primary's base
    `color: #fff` is already the right polarity and only needs re-pointing at bone. By
    NIGHT --accent is bone, which is the DARK theme's situation, so the text must go
    dark. Copying the dark block's rules into both faces would give the day face dark
    text on a dark fill.                                                                */
[data-theme="instrument-day"] .btn-primary           { background: var(--accent);    border-color: var(--accent);    color: #FFFFFF; }
[data-theme="instrument-day"] .btn-primary:hover     { background: var(--accent-dn); border-color: var(--accent-dn); color: #FFFFFF; }
[data-theme="instrument-day"] .pill-navy             { background: var(--deep); color: #FFFFFF; }
[data-theme="instrument-day"] .accordion-header.open { background: var(--deep); color: #FFFFFF; }

[data-theme="instrument-night"] .btn-primary           { background: var(--accent);    border-color: var(--accent);    color: #101314; }
[data-theme="instrument-night"] .btn-primary:hover     { background: var(--accent-dn); border-color: var(--accent-dn); color: #101314; }
[data-theme="instrument-night"] .pill-navy             { background: var(--deep); color: #C1C4C2; }
[data-theme="instrument-night"] .accordion-header.open { background: var(--deep); color: #C1C4C2; }
[data-theme="instrument-night"] .btn-danger            { color: #101314; }
[data-theme="instrument-night"] .btn-danger:hover      { background: #FF9A90; border-color: #FF9A90; color: #101314; }
[data-theme="instrument-night"] .viewing-as-badge:hover { color: #101314; }
[data-theme="instrument-night"] .stepper-circle.done,
[data-theme="instrument-night"] .stepper-circle.current,
[data-theme="instrument-night"] .order-step-circle.done,
[data-theme="instrument-night"] .order-step-circle.current { color: #101314; }
[data-theme="instrument-night"] .role-chip-x:hover     { color: #101314; }
[data-theme="instrument-night"] .theme-toggle.on .theme-toggle-knob { background: #101314; }
[data-theme="instrument-night"] .spinner-ink { border-color: rgba(237, 239, 236, 0.22); border-top-color: var(--navy); }

/* Flash and toast borders follow each face's status inks. */
[data-theme="instrument-day"] .flash-ok,
[data-theme="instrument-day"] .toast-ok      { color: var(--ok-ink);   border-color: var(--ok-ink); }
[data-theme="instrument-day"] .flash-warn,
[data-theme="instrument-day"] .toast-warn    { color: var(--warn-ink); border-color: var(--warn-ink); }
[data-theme="instrument-day"] .toast-bad     { color: var(--bad-ink);  border-color: var(--bad-ink); }
[data-theme="instrument-night"] .flash-ok,
[data-theme="instrument-night"] .toast-ok    { color: var(--ok);   border-color: #1F5A3E; }
[data-theme="instrument-night"] .flash-warn,
[data-theme="instrument-night"] .toast-warn  { color: var(--warn); border-color: #6B3A08; }
[data-theme="instrument-night"] .toast-bad   { color: var(--bad);  border-color: #6B322C; }

/* --- INSTRUMENT wave 2: the chassis is not a face --------------------- */
/*  Measured, not reasoned: the wave-1 walk over 61 route/viewport rows reported
    457 contrast failures by DAY and 2 by night, and the two night ones are
    OpenStreetMap's own attribution control (this stylesheet contains zero
    `leaflet` rules, so it renders identically in every theme and always has).

    ⚠⚠ THE WHOLE OF THE DAY FAILURE IS ONE SENTENCE: CARD INK PAINTED ON THE
    CHASSIS. Two colour pairs are 96% of it — #454C4D on #15191A (254 rows) and
    #101314 on #15191A (188 rows). Page titles, subtitles, the brand footer, tab
    strips and filter chips are all mounted on the CANVAS, not on a card, and
    Blueprint never noticed because its canvas (#FAF9F7) and its card (#FFFFFF)
    are 1.03:1 apart. Instrument puts them 16:1 apart, so every surface that was
    never really assigned now says so out loud. That is a finding about the
    markup, not about the palette.

    ⚠⚠ WHY THIS IS SIX RULES AND NOT THIRTY. Custom properties cascade, so
    re-pointing the ink tokens ON the chassis makes every existing component rule
    that says var(--text-muted) resolve to the chassis tier with no edit to the
    component. Enumerating the thirty offending classes by hand would have fixed
    exactly the thirty this walk happened to reach, and left every unvisited page
    and every future component broken in the same way. This is self-correcting
    instead: a painted face that is missing from the reset list below shows up
    IMMEDIATELY as light-on-bone, which the harness measures in both directions.

    ⚠ NIGHT IS ALMOST A NO-OP HERE, and that is expected rather than suspicious:
    at night the face and the chassis are both dark, so the ink never had to
    flip. The remap is still applied to both faces so there is ONE mechanism to
    understand rather than a day-only special case.                             */

/*  ⚠ THE REMAP MUST REACH EVERY CHASSIS-FAMILY SURFACE, NOT JUST .app-shell.
    Three groups were missed on the first pass and the walk named all three:

      .clock-page   the two bare field shells (/clock, /quicklog) are [StaticRender]
                    and render their OWN root - they never see .app-shell, so their
                    back-link and brand footer kept card ink on the chassis.
      the bay       .brand-footer, .bg-warm, .navrail and the board/dispatch headers
                    paint var(--surface). The bay is HOUSING, not a face, so text on
                    it belongs to the bd tiers - Blueprint could not tell, because its
                    surface and its card are 1.02:1 apart.
      the one       .company-sections > .box:has(.fold) sets background: transparent.
      hollow .box   It matches .box, so it inherited face-ink from the reset below,
                    and then painted nothing - dark ink straight onto the chassis.
                    A .box that is not a box is exactly the kind of thing a 16:1
                    inversion finds and a 1.03:1 one hides.                          */
[data-theme="instrument-day"] .clock-page,  [data-theme="instrument-night"] .clock-page,
[data-theme="instrument-day"] .brand-footer, [data-theme="instrument-night"] .brand-footer,
[data-theme="instrument-day"] .bg-warm,      [data-theme="instrument-night"] .bg-warm,
[data-theme="instrument-day"] .navrail,      [data-theme="instrument-night"] .navrail,
[data-theme="instrument-day"] .punch-room,   [data-theme="instrument-night"] .punch-room,
[data-theme="instrument-day"] .bp-rail,      [data-theme="instrument-night"] .bp-rail,
[data-theme="instrument-day"] .dispatch-corner,  [data-theme="instrument-night"] .dispatch-corner,
[data-theme="instrument-day"] .dispatch-colhead, [data-theme="instrument-night"] .dispatch-colhead,
[data-theme="instrument-day"] .dispatch-daylabel,[data-theme="instrument-night"] .dispatch-daylabel,
[data-theme="instrument-day"] .boards-corner,    [data-theme="instrument-night"] .boards-corner,
[data-theme="instrument-day"] .boards-colhead,   [data-theme="instrument-night"] .boards-colhead,
[data-theme="instrument-day"] .boards-lanehead,  [data-theme="instrument-night"] .boards-lanehead,
[data-theme="instrument-day"] .company-sections > .box:has(.fold),
[data-theme="instrument-night"] .company-sections > .box:has(.fold),
[data-theme="instrument-day"] .company-sections > * > .box:has(.fold),
[data-theme="instrument-night"] .company-sections > * > .box:has(.fold),
[data-theme="instrument-day"] .app-shell,  [data-theme="instrument-day"] .dash,
[data-theme="instrument-night"] .app-shell, [data-theme="instrument-night"] .dash {
    /* On the chassis, "ink" means bone. Every descendant rule follows without
       being touched. */
    --ink:        var(--bd1);
    --navy:       var(--bd1);
    --navy-soft:  var(--bd2);
    --text-muted: var(--bd2);
    --ink3:       var(--bd3);
    --glyph-ui:   var(--bd2);
    color: var(--bd1);
}

/*  ...and every painted face puts them back. --face-ink* exist so this is ONE
    rule rather than a day copy and a night copy that can drift apart.
    ⚠ .bp-chip is deliberately ABSENT — it stops being a face under Instrument;
    see the chip rules below. */
[data-theme^="instrument"] body,
[data-theme^="instrument"] .box,
[data-theme^="instrument"] .box-soft,
[data-theme^="instrument"] .kpi-box,
[data-theme^="instrument"] .tbl-wrap,
[data-theme^="instrument"] .tbl-cards tbody tr,
[data-theme^="instrument"] .kanban-card,
[data-theme^="instrument"] .accordion,
[data-theme^="instrument"] .modal-card,
[data-theme^="instrument"] .order-card,
[data-theme^="instrument"] .expense-card,
[data-theme^="instrument"] .reconnect-card,
[data-theme^="instrument"] .boards-card,
[data-theme^="instrument"] .boards-scroll,
[data-theme^="instrument"] .dispatch-scroll,
[data-theme^="instrument"] .dispatch-map,
[data-theme^="instrument"] .dispatch-chip,
[data-theme^="instrument"] .bp-head,
[data-theme^="instrument"] .bp-pcell,
[data-theme^="instrument"] .bp-recno,
[data-theme^="instrument"] .bp-spine-step,
[data-theme^="instrument"] .bp-step-c,
[data-theme^="instrument"] .nav-dropdown,
[data-theme^="instrument"] .user-menu,
[data-theme^="instrument"] .topbar-app,
[data-theme^="instrument"] .topbar-nav,
[data-theme^="instrument"] .topbar-search-drop,
[data-theme^="instrument"] .mobilesearch-sheet,
[data-theme^="instrument"] .mobilenav,
[data-theme^="instrument"] .search-select-menu,
[data-theme^="instrument"] .search-input,
[data-theme^="instrument"] .askw-assistant,
[data-theme^="instrument"] .askw-chip,
[data-theme^="instrument"] .askw-input,
[data-theme^="instrument"] .role-chip,
[data-theme^="instrument"] .co-row,
[data-theme^="instrument"] .je-row,
[data-theme^="instrument"] .fh-punch,
[data-theme^="instrument"] .fh-big,
[data-theme^="instrument"] .skip-link {
    --ink:        var(--face-ink);
    --navy:       var(--face-ink);
    --navy-soft:  var(--face-ink2);
    --text-muted: var(--face-ink2);
    --ink3:       var(--face-ink3);
    --glyph-ui:   var(--face-ink2);
    color: var(--face-ink);
}

/*  A chip is a KEY, and a key is either lit or unlit. Blueprint had this the
    other way round — the resting chip was a white card and the selected one a
    translucent tint — which on the chassis leaves the SELECTED chip as dark ink
    over a 10% tint over graphite, i.e. 1.05:1. It was the single most-repeated
    failure in the walk after the page titles. */
[data-theme^="instrument"] .bp-chip {
    background: transparent;
    color: var(--bd2);
    border-color: var(--bd3);
}
[data-theme^="instrument"] .bp-chip:hover { color: var(--bd1); border-color: var(--bd2); }
[data-theme^="instrument"] .bp-chip.on {
    background: var(--card);
    border-color: var(--card);
    color: var(--face-ink);
}

/*  The ledger tab strip is mounted on the chassis, and its active tab is the lit
    key — same shape as the chips, same reason. */
[data-theme^="instrument"] .ledger-tab { color: var(--bd2); }
[data-theme^="instrument"] .ledger-tab:hover { color: var(--bd1); }
[data-theme^="instrument"] .ledger-tab.active {
    background: var(--card);
    color: var(--face-ink);
}



/* --- Reset ------------------------------------------------------------ */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

/* Form controls do NOT inherit font-family — they fall back to the UA font, so a
   bare one renders in Arial while the page around it is Inter.
   Originally scoped to <select> alone, with the note "input/textarea/button have
   their own styling rules and sweeping them in here would change more than was
   measured". 2026-08-15: it HAS now been measured, in the browser, across seven
   routes. Every remaining Arial text node in the product traced to a <button>:

     button.askw-fab        20px "✦"        the AI launcher, on 45 of 53 pages
     button.topbar-user-btn  —              and therefore ALL THREE of its children:
       div.topbar-avatar    12px "A"          the signed-in initial
       span.topbar-inbox-badge 10px "9+"      the unread count
       span.topbar-user-caret  14px "▾"       the menu caret
     button.li-opt          15px "☆"        estimate line "optional" toggle
     button.li-del          14px "✕"        estimate line delete
     button                 13px "+ Add item"

   The three top-bar children are spans and divs — they were Arial because their
   BUTTON parent was, which is why a select-only fix could never reach them. That
   set appears in the corner of every page in the product.
   ⚠ Bare element selectors, so every class rule (.btn, .mono, …) still wins on
   specificity; this only catches the controls nothing else styles. */
select, button, input, textarea { font-family: inherit; }

html, body {
    height: 100%;
    overflow: hidden;
}

body {
    font-family: var(--font-sans);
    font-size: var(--fs-base);
    line-height: 1.5;
    letter-spacing: -0.005em;
    color: var(--navy);
    background: var(--card);
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
    text-rendering: optimizeLegibility;
}

/* --- App Shell ------------------------------------------------------- */
.app-shell {
    display: flex;
    flex-direction: column;
    height: 100vh;
    overflow: hidden;
}

/* --- Top Bar ---------------------------------------------------------- */
.topbar {
    height: var(--topbar-h);
    flex-shrink: 0;
    display: flex;
    align-items: center;
    padding: 0 16px;
    gap: 14px;
    background: linear-gradient(to bottom, var(--card), var(--paper-warm));
    border-bottom: 1px solid var(--line);
    z-index: 100;
}

/* The brand line in the top bar. Was Caveat (handwriting) under the old
   identity; Discovery ONE's mark is a geometric sans, so a script face here
   read as a different product to the one on the sign-in screen. Inter with
   tight tracking and a small optical size is the closest the shipped font
   stack gets to the wordmark. */
.topbar-logo {
    font-family: 'Inter', system-ui, sans-serif;
    font-size: 18px;
    font-weight: 700;
    color: var(--navy);
    letter-spacing: -0.01em;
    text-decoration: none;
    white-space: nowrap;
}

.topbar-nav {
    display: flex;
    gap: 2px;
    flex: 0 0 auto;   /* the search slot takes the flexible center space */
}

.topbar-nav a,
.topbar-nav button {
    font-family: var(--font-sans);
    font-size: var(--fs-sm);
    font-weight: 400;
    color: var(--navy-soft);
    text-decoration: none;
    padding: 5px 12px;
    border-radius: var(--r-pill);
    border: none;
    background: transparent;
    cursor: pointer;
    transition: background 0.15s, color 0.15s;
    white-space: nowrap;
}

.topbar-nav a:hover,
.topbar-nav button:hover {
    background: var(--sky-light);
    color: var(--navy);
}

.topbar-nav a.active,
.topbar-nav button.active {
    background: var(--sky-light);
    color: var(--navy);
    /*font-weight: 600;*/
}

/* Two-level nav: a group header pill that reveals a dropdown of its children on
   hover / keyboard focus. The header <a> inherits the pill styling above. */
.nav-group { position: relative; display: flex; align-items: center; }
.nav-group-btn { display: inline-flex; align-items: center; gap: 4px; }
.nav-caret { font-size:12px; opacity: 0.55; }  /* glyph — off the type scale */
.nav-dropdown {
    display: none;
    position: absolute;
    top: 100%;
    left: 0;
    min-width: 190px;
    flex-direction: column;
    gap: 2px;
    padding: 6px;
    background: var(--card);
    border: 1px solid var(--line);
    border-radius: var(--r);
    box-shadow: var(--shadow-md);
    z-index: 250;
}
/* :has(:focus-visible), not :focus-within — a mouse click focuses the link
   but doesn't set :focus-visible, so after an SPA navigation (the top bar's
   DOM survives, the clicked link keeps focus) the dropdown doesn't stay
   pinned open. Keyboard (Tab) focus still reveals it. */
.nav-group:hover > .nav-dropdown,
.nav-group:has(:focus-visible) > .nav-dropdown { display: flex; }
.nav-dropdown a { width: 100%; text-align: left; white-space: nowrap; }

/* Site-wide search: a real input living in the top bar. The pill flexes with
   the bar (min-width:0 lets it shrink on narrow screens — never an icon-only
   collapse), and results drop down anchored under the field. */
.topbar-search-slot { flex: 1 1 auto; display: flex; justify-content: end; min-width: 0; }
/* Mockup .search: a soft RECTANGLE (8px), warm-white field on the white bar,
   13px muted text, 9px gap, with the Ctrl K chip riding the right edge. */
.topbar-search {
    position: relative;
    display: inline-flex;
    align-items: center;
    gap: 9px;
    width: 100%;
    max-width: 400px;
    min-width: 0;
    padding: 7.5px 12px;
    font-family: var(--font-sans);
    font-size: var(--fs-sm);
    color: var(--navy-soft);
    background: var(--paper-cool);
    border: 1px solid var(--line);
    border-radius: var(--r-sm);
}
.topbar-search:hover,
.topbar-search:focus-within { border-color: var(--sky); color: var(--navy); }
.topbar-search-icon { flex: none; display: inline-flex; color: var(--navy-soft); }

/* The keyboard hint, mockup .search kbd — mono, hairline, white chip. */
.topbar-search-kbd {
    flex: none;
    margin-left: auto;
    font: 500 10.5px/1 var(--font-mono);
    color: var(--ink3);
    background: var(--card);
    border: 1px solid var(--line);
    border-radius: 5px;
    padding: 3px 6px;
    white-space: nowrap;
}
.topbar-search-input {
    flex: 1;
    min-width: 0;
    border: none;
    outline: none;
    background: transparent;
    font-family: inherit;
    font-size: inherit;
    color: var(--navy);
    padding: 0;
}
.topbar-search-input::placeholder { color: var(--navy-soft); }
.topbar-search-clear {
    flex-shrink: 0;
    border: none;
    background: transparent;
    color: var(--navy-soft);
    font-size: var(--fs-xs);
    line-height: 1;
    padding: 2px;
    cursor: pointer;
}
.topbar-search-clear:hover { color: var(--navy); }

/* Results panel, anchored under the field. Above the click-away backdrop
   (.menu-backdrop, z-index 200 — same layering as the user menu). */
.topbar-search-drop {
    position: absolute;
    top: calc(100% + 8px);
    right: 0;
    z-index: 201;
    width: min(420px, calc(100vw - 24px));
    background: var(--card);
    border: 1px solid var(--line);
    border-radius: var(--r);
    box-shadow: var(--shadow-md);
    padding: 6px;
}
.sitesearch-status { color: var(--navy-soft); font-size: var(--fs-base); padding: 14px 4px; text-align: center; }
.sitesearch-results { display: flex; flex-direction: column; gap: 1px; max-height: 54vh; overflow-y: auto; margin: 0 -4px; }
.sitesearch-group-label {
    font-size: var(--fs-2xs); font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em;
    color: var(--navy-soft); padding: 10px 8px 3px;
}
.sitesearch-hit {
    display: flex; align-items: center; gap: 10px; width: 100%;
    padding: 8px 10px; border: none; background: transparent; border-radius: var(--r-sm);
    cursor: pointer; text-align: left;
}
.sitesearch-hit:hover { background: var(--sky-light); }
.sitesearch-hit-icon { font-size: 16px; width: 22px; text-align: center; flex-shrink: 0; }
.sitesearch-hit-main { display: flex; flex-direction: column; min-width: 0; flex: 1; }
.sitesearch-hit-title { font-weight: 600; color: var(--navy); font-size: var(--fs-md); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sitesearch-hit-sub { color: var(--navy-soft); font-size: var(--fs-sm); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* Image lightbox (ZoomImage): a full-screen view of an uploaded thumbnail, sitting
   above modals (whose overlay is z-index 1000) so receipts/PO scans can be enlarged
   from inside their edit dialogs. */
.zoomable { cursor: zoom-in; }
/* The keyboard-reachable wrapper ZoomImage puts around its thumbnail (T18).
   Bare: the <img> inside keeps carrying all the visual styling, so existing
   call sites render pixel-identically. display:contents would be tidier but
   removes the button from the tab order in some engines — inline-block + zero
   chrome is the safe shape. */
.zoom-trigger {
    background: none; border: none; padding: 0; margin: 0;
    display: inline-block; cursor: zoom-in; line-height: 0;
}
.lightbox-overlay {
    position: fixed;
    inset: 0;
    z-index: 1200;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 40px;
    background: rgba(15, 23, 42, 0.82);
    cursor: zoom-out;
    animation: lightbox-in 0.12s ease-out;
}
@keyframes lightbox-in { from { opacity: 0; } to { opacity: 1; } }
.lightbox-img {
    max-width: 94vw;
    max-height: 92vh;
    object-fit: contain;
    border-radius: var(--r-sm);
    box-shadow: 0 12px 48px rgba(0, 0, 0, 0.5);
    cursor: default;
}
.lightbox-close {
    position: fixed;
    top: 16px;
    right: 20px;
    width: 40px;
    height: 40px;
    border-radius: 50%;
    border: none;
    background: rgba(255, 255, 255, 0.16);
    color: #fff;
    font-size: 17px;
    line-height: 1;
    cursor: pointer;
}
.lightbox-close:hover { background: rgba(255, 255, 255, 0.28); }

/* Company page: the three settings cards (Company Information, Accounting Software,
   Payment Connections) sit in a row on wide screens and wrap/stack when narrow.
   The `>` combinator targets only the top-level cards, not the connector/processor
   cards nested inside them. */
/* ⚠⚠ ONE COLUMN OF ROWS, NOT A MASONRY OF CARDS — 2026-08-05. This was
   `flex-wrap: wrap` with `flex: 1 1 340px` cards, which was right while each
   panel was a block of visible content. Once every panel folded, it stopped
   being right in two ways at once:

     · A card whose entire contents are one collapsed row is pure chrome. The
       border exists to group content that is no longer on screen.
     · The wrap produced RAGGED heights — the company card carries two rows, the
       accounting and payment cards one each — so three cards of different
       heights sat in a row with dead space under two of them. That unevenness
       is most of what still read as "messy" after the folding landed.

   Worse, it made one kind of thing look like two: `Payment Connections` was a
   card-containing-one-row while `Payments` was a row-in-a-list, though they are
   the same object at the same level. One rhythm, one width, one kind of row. */
.company-sections {
    display: flex;
    flex-direction: column;
    gap: 0;
    max-width: 900px;
}
/* The outer panels lose their card chrome and become sections of the list. The
   `:has(.fold)` test is what keeps this off the INNER cards — the connector and
   processor tiles inside the accounting panel are real cards and stay cards. */
.company-sections > .box:has(.fold),
.company-sections > * > .box:has(.fold) {
    border: none;
    border-radius: 0;
    background: transparent;
    padding: 0;
    max-width: none;
    margin: 0;
}
/* A hairline between every row, including across panel boundaries, so the list
   reads as one list rather than as groups that happen to be adjacent. */
.company-sections > .box:has(.fold) + .box:has(.fold) > .fold:first-child,
.company-sections .fold + .fold { border-top: 1px solid var(--line); }
.company-sections .fold { border-bottom: none; }
.company-sections > .box { flex: none; }
/* --- Group headings in the settings list --------------------------------
   ⚠⚠ THESE ARE HEADINGS, NOT CAPTIONS — and they inherited the caption spec
   until they were called out as unreadable. `.section-header` is 10.5px mono
   UPPERCASE at 0.16em, muted: the Blueprint idiom for a drawing-title-block
   CAPTION, deliberately recessive.

   That is the same mistake, one level up, that the fold rows had: uppercase
   destroys word shape, wide tracking pulls letters apart, monospace removes
   proportional rhythm — three compounding penalties, which a two-word caption
   can absorb and a heading naming a whole group of the page cannot. "GETTING
   MORE WORK" at 10.5px was smaller and quieter than the rows it introduced,
   which inverts the hierarchy it exists to create.

   So: sans, sentence case, 17px/700, ink — LARGER than the 15px/600 rows
   beneath, giving a real ladder of page 21 → group 17 → row 15 → label 11.
   The mono micro-cap stays where it belongs: field labels and captions inside
   an expanded section.
   ⚠ Scoped to this list. `.section-header` is used 94 times elsewhere and its
   caption spec is correct in every one of them. */
.company-sections > .section-header,
.settings-col > .section-header:first-child {
    font-family: var(--font-sans);
    font-size: 17px;
    font-weight: 700;
    text-transform: none;
    letter-spacing: -0.01em;
    color: var(--ink);
    /* No rule under it: the rows below carry their own hairlines, and a third
       line here made the heading look like another row rather than a heading. */
    border-bottom: none;
    padding-bottom: 0;
    margin: 34px 0 2px;
}
/* Not 0 — the first heading still needs to clear the page's own promise line,
   or it reads as a continuation of the page subtitle. */
.company-sections > .section-header:first-child { margin-top: 20px; }
.company-sections > .flash {
    flex: 1 1 100%;   /* a connection flash spans the full width, above the cards */
    order: -1;
    margin: 0;
}

.topbar-meta {
    margin-left: auto;
    display: flex;
    align-items: center;
    gap: 14px;
}

.topbar-date {
    font-size: var(--fs-sm);
    font-weight: 600;
    color: var(--navy-soft);
    white-space: nowrap;
    letter-spacing: 0.02em;
}

.viewing-as-badge {
    display: inline-flex;
    align-items: center;
    gap: 5px;
    font-size: var(--fs-xs);
    font-weight: 700;
    color: var(--navy);
    background: var(--warn-light);
    border: 1px solid var(--warn);
    padding: 3px 10px;
    border-radius: var(--r-pill);
    text-decoration: none;
    white-space: nowrap;
}
/* ⚠ The :visited twin is MANDATORY here, not decorative — see the .btn block's
   note. This badge is an <a href="/roles">, so the moment an operator visits
   /roles (which is exactly where they went to start impersonating) `a:visited`
   (0,1,1) outranks this single class (0,1,0) and repaints the label
   var(--accent) on --warn-light: 4.24:1, under AA, on the one control that
   tells you you are not seeing your own permissions.
   ⚠ (0,2,0) wins in BOTH themes, so unlike .btn-primary this needs no dark
   counterpart. The :hover pair below was already safe for the same reason. */
.viewing-as-badge, .viewing-as-badge:visited { color: var(--navy); }
.viewing-as-badge:hover { background: var(--warn-ink); color: #fff; }

.topbar-role-pill {
    background: var(--fill);
    color: var(--navy);
    font-size: var(--fs-xs);
    font-weight: 600;
    padding: 3px 10px;
    border-radius: var(--r-pill);
    border: none;
    cursor: pointer;
}
.topbar-role-pill:hover { background: var(--sky-light); }

/* Mockup .avatar: 32px ink circle, 600/12px initials. */
.topbar-avatar {
    width: 32px;
    height: 32px;
    flex-shrink: 0;
    border-radius: 50%;
    background: var(--navy);
    color: var(--card);
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 12px;   /* avatar initials — glyph sizing */
    font-weight: 600;
}
.topbar-avatar.lg { width: 34px; height: 34px; font-size:12px; }

/* --- User dropdown ---------------------------------------------------- */
.topbar-user { position: relative; }

.topbar-user-btn {
    display: flex;
    align-items: center;
    gap: 4px;
    padding: 3px 6px 3px 3px;
    border: 1px solid transparent;
    border-radius: var(--r-pill);
    background: transparent;
    cursor: pointer;
    transition: background 0.12s, border-color 0.12s;
}
.topbar-user-btn:hover,
.topbar-user-btn.open { background: var(--fill); border-color: var(--line); }
.topbar-user-caret { font-size: 14px; color: var(--navy-soft); }

.menu-backdrop {
    position: fixed;
    inset: 0;
    z-index: 200;
}

/* --- Notification inbox (top-bar bell) --------------------------------- */
.topbar-inbox { position: relative; display: flex; align-items: center; }
.topbar-bell { font-size: 15px; line-height: 1; }
.topbar-inbox-badge {
    position: absolute;
    top: -2px;
    right: -4px;
    min-width: 16px;
    height: 16px;
    padding: 0 4px;
    border-radius: var(--r-pill);
    background: var(--bad);
    color: var(--card);   /* --bad flips light in dark mode; --paper is dark there */
    font-size: 10px;       /* badge glyph sizing */
    font-weight: 700;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    pointer-events: none;
}
.inbox-panel { width: 320px; }
.inbox-list { display: flex; flex-direction: column; gap: 1px; max-height: 54vh; overflow-y: auto; }
.inbox-item {
    display: flex;
    align-items: flex-start;
    gap: 8px;
    width: 100%;
    padding: 8px;
    border: none;
    background: transparent;
    border-radius: var(--r-sm);
    cursor: pointer;
    text-align: left;
    font-size: var(--fs-sm);
    color: var(--navy);
}
.inbox-item:hover { background: var(--sky-light); }
.inbox-item-dot { width: 8px; height: 8px; border-radius: 50%; margin-top: 5px; flex-shrink: 0; }
.inbox-dot-bad  { background: var(--bad); }
.inbox-dot-warn { background: var(--warn); }
.inbox-dot-info { background: var(--chart-blue); }
.inbox-item-icon { flex-shrink: 0; }
.inbox-item-text { min-width: 0; overflow-wrap: anywhere; }
.inbox-empty { padding: 22px 8px; text-align: center; color: var(--text-muted); font-size: var(--fs-sm); }

.user-menu {
    position: absolute;
    top: calc(100% + 8px);
    right: 0;
    z-index: 201;
    width: 248px;
    background: var(--card);
    border: 1px solid var(--line);
    border-radius: var(--r);
    box-shadow: var(--shadow-md);
    padding: 8px;
    display: flex;
    flex-direction: column;
    gap: 4px;
}

.user-menu-head {
    align-items: center;
    padding: 6px 8px;
}
/* min-width:0 lets the text column shrink inside the fixed-width menu —
   without it a long email refuses to shrink and overflows the box. */
.user-menu-id   { min-width: 0; flex: 1; }
.user-menu-name { font-size: var(--fs-md); font-weight: 700; color: var(--navy); overflow-wrap: anywhere; }
.user-menu-sub  { font-size: var(--fs-2xs); color: var(--navy-soft); overflow-wrap: anywhere; text-align: center; }

.user-menu-sep { height: 1px; background: var(--line); margin: 4px 0; }

.user-menu-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 6px 8px;
}
.user-menu-label { font-size: var(--fs-sm); font-weight: 600; color: var(--navy); }

.user-menu-item {
    display: flex;
    align-items: center;
    gap: 9px;
    width: 100%;
    padding: 8px;
    border: none;
    background: transparent;
    border-radius: var(--r-sm);
    font-size: var(--fs-base);
    font-weight: 600;
    color: var(--navy);
    cursor: pointer;
    text-align: left;
    transition: background 0.12s;
}
.user-menu-item:hover { background: var(--sky-soft); }
.user-menu-item-icon { font-size: 14px; }

/* --- Theme toggle switch ---------------------------------------------- */
.theme-toggle {
    width: 38px;
    height: 20px;
    border-radius: var(--r-pill);
    border: 1px solid var(--line-mid);
    background: var(--fill);
    cursor: pointer;
    padding: 0;
    position: relative;
    flex-shrink: 0;
    transition: background 0.15s, border-color 0.15s;
}
.theme-toggle.on { background: var(--sky); border-color: var(--sky); }
.theme-toggle-knob {
    position: absolute;
    top: 1px;
    left: 1px;
    width: 16px;
    height: 16px;
    border-radius: 50%;
    background: #fff;
    box-shadow: 0 1px 2px rgba(0,0,0,0.3);
    transition: transform 0.15s;
}
.theme-toggle.on .theme-toggle-knob { transform: translateX(18px); }

/* --- Page Layout ------------------------------------------------------ */
/* Column layout: full-width status strip / breadcrumb on top, with the page
   body (and any detail sidebar + content) stacked underneath it. */
.page-body {
    display: flex;
    flex-direction: column;
    flex: 1;
    min-width: 0;
    overflow: hidden;
}

/* --- Navigation rail --------------------------------------------------- */
/* The accordion sidebar (DiscoveryOne.Web's SideNav) in a rail-first shell: the
   rail runs full height with the brand at its top; the top bar spans only
   the content column. Mobile (≤1024px): off-canvas overlay under the top
   bar, toggled by the burger. */
.app-shell-rail { flex-direction: row; }

.app-main {
    display: flex;
    flex-direction: column;
    flex: 1;
    min-width: 0;
    min-height: 0;
}

/* Web-app top bar (scoped — the ControlPlane keeps the plain .topbar): a
   roomier toolbar with the brand in the rail. The search owns the left edge on
   EVERY page. There is no breadcrumb: it repeated the page's own h1/sub-title,
   and navigation reads more naturally in the rail. */
/* Mockup .topbar: flat white bar, 22px gutters, 12px gap (the legacy gradient
   stays on the ControlPlane's plain .topbar). */
.topbar-app { padding: 0 22px; gap: 12px; background: var(--card); }
.topbar-app .topbar-search-slot { justify-content: flex-start; }
.topbar-logo-m { display: none; }

/* The rail goes off-canvas at ≤1024px, so the small brand and the bottom nav
   (whose More button is the only rail toggle — the web app has no burger)
   must appear at the SAME breakpoint, or a width band is left with no
   navigation at all. */
@media (max-width: 1024px) {
    /* The brand MUST be able to shrink. A tenant logo renders at its own
       Company.LogoWidth, and .topbar-logo is nowrap with min-width:auto, so
       without these three lines a wide wordmark is an immovable block: the
       search slot collapses to 0 trying to compensate, the bar still doesn't
       fit, and the whole document ends up wider than the phone — which makes
       a mobile webview scale the entire page down to fit. Measured at 385px
       with a 231px wordmark: the bar needed 421px. */
    .topbar-logo-m { display: flex; align-items: center; flex: 0 1 auto; min-width: 0; max-width: 40vw; overflow: hidden; }
    .topbar-logo-m img { max-width: 100%; height: auto; object-fit: contain; }

    /* The profile moves into the bottom nav at these widths. */
    .topbar-app .topbar-user { display: none; }
}

/* Phones: the search field cannot be typed into at these widths — it measured
   52px at 390px, since the brand + New + bell take the bar. So the field goes
   from the BAR and moves to the bottom nav's Search sheet, which gives it the
   full width it needs. Site search stays inline on tablet and up. */
@media (max-width: 600px) {
    .topbar-app .topbar-search-slot { display: none; }
}

/* The phone search sheet (bottom nav → Search). Sits above the nav pill and
   below nothing else; the same SiteSearchBox the top bar uses, given the whole
   width. Its own results dropdown renders inside it. */
.mobilesearch-backdrop {
    position: fixed;
    inset: 0;
    background: color-mix(in srgb, var(--ink) 22%, transparent);
    z-index: 520;
}
.mobilesearch-sheet {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    z-index: 530;
    padding: 10px 12px calc(10px + env(safe-area-inset-top));
    background: var(--card);
    border-bottom: 1px solid var(--line);
}
.mobilesearch-sheet .topbar-search { max-width: none; width: 100%; }

/* --- Mobile bottom nav (the original app's .mobile-nav) ---------------- */
/* Home · Projects · camera circle (Quick Log) · More · Profile, restyled as a
   floating translucent pill (the Instagram-app look): the page scrolls visibly
   behind the frosted glass, the active item sits in a soft bubble, and the
   whole bar eases smaller while scrolling down and pops back on the first
   upward scroll (App.razor toggles .mobilenav-min). Modal overlays (z-index
   1000) sit above and cover it. */
.mobilenav {
    display: none;
    position: fixed;
    bottom: calc(10px + env(safe-area-inset-bottom));
    left: 8px;
    right: 8px;
    margin: 0 auto;
    max-width: 560px;
    height: 60px;
    border-radius: 30px;
    /* Solid fallback first; frosted glass where the browser can do it. */
    background: var(--card);
    background: color-mix(in srgb, var(--card) 32%, transparent);
    -webkit-backdrop-filter: blur(14px) saturate(1.4);
    backdrop-filter: blur(14px) saturate(1.4);
    border: 1px solid color-mix(in srgb, var(--line) 65%, transparent);
    box-shadow: 0 10px 30px rgba(0, 0, 0, 0.22);
    z-index: 500;
    padding: 0 6px;
    transform-origin: bottom center;
    transition: transform 0.25s ease, opacity 0.25s ease;
}
/* Scrolling down: recede a touch (still tappable); scrolling up brings it back. */
.mobilenav.mobilenav-min { transform: scale(0.92) translateY(6px); opacity: 0.85; }
.mobilenav-item {
    flex: 1;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 2px;
    background: none;
    border: none;
    border-radius: 22px;
    margin: 7px 2px;
    font-family: inherit;
    /* px, not a rem token: at 200% browser text this row is fixed-width
       (position:fixed pill, no wrap, body overflow-x:hidden), so a scaling
       label pushes the last item off-screen where it cannot be reached. */
    font-size: 11px;
    font-weight: 500;
    color: var(--text-muted);
    text-decoration: none;
    cursor: pointer;
    padding: 4px 6px;
    /* Allow the item to shrink below its label's min-content width. */
    min-width: 0;
    overflow: hidden;
    transition: background 0.15s ease, color 0.15s ease;
}
/* :visited twin — (0,2,0) beats `a, a:visited` (0,1,1). See VisitedCascadeTests. */
.mobilenav-item, .mobilenav-item:visited { color: var(--text-muted); }
.mobilenav-item > span {
    max-width: 100%;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
.mobilenav-item.active {
    color: var(--navy);
    font-weight: 700;
    background: color-mix(in srgb, var(--navy) 10%, transparent);
}
.mobilenav-item svg { width: 22px; height: 22px; }
.mobilenav-camera {
    flex: 1;
    display: flex;
    align-items: center;
    justify-content: center;
    text-decoration: none;
    padding: 0;
}
.mobilenav-camera-circle {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 44px;
    height: 44px;
    border-radius: 50%;
    background: var(--navy);
    color: var(--card);   /* --navy flips light in dark mode; --paper is dark there */
    box-shadow: 0 2px 8px rgba(0,0,0,0.2);
}
.mobilenav-camera-circle svg { width: 24px; height: 24px; }
.mobilenav-camera:active .mobilenav-camera-circle { transform: scale(0.94); }
.mobilenav-avatar {
    width: 22px;
    height: 22px;
    border-radius: 50%;
    /* A lifted ink, not ink itself — it sits beside the near-black camera FAB
       in the same bar and the two must not read as one control. */
    background: #3a3a38;
    background: color-mix(in srgb, var(--ink) 80%, var(--paper));
    color: #fff;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    font-size: 9px;   /* avatar initials — glyph sizing */
    font-weight: 700;
}
/* The account menu opened from the bottom bar sits above it, not below the
   trigger (the .user-menu base rule anchors top-right under the top bar). */
.mobilenav .user-menu {
    position: fixed;
    top: auto;
    left: auto;
    right: 12px;
    bottom: calc(80px + env(safe-area-inset-bottom));
}

@media (max-width: 1024px) {
    .mobilenav { display: flex; }
    /* Tablets keep the fixed-pane layout: inset the content column so its
       inner scroll areas end above the floating pill (60px bar + 10px gap). */
    .app-main { padding-bottom: calc(148px + env(safe-area-inset-bottom)); }
}
@media (max-width: 760px) {
    /* Phones scroll as one document — the padding moves to the body. */
    .app-main { padding-bottom: 0; }
    body { padding-bottom: calc(148px + env(safe-area-inset-bottom)); }
}
/* ⚠ 148px, not 78px: the bottom nav is only the FIRST floating thing. The Ask
   widget's .askw-fab sits at bottom:86px and is ~56px tall, so 78px of padding
   left the LAST row of every page permanently under it — measured on the
   dashboard, where the FAB covered the "Assignments · next 7 days" tile with
   nothing left to scroll. 86 + 56 + 6 = 148 clears both. Mid-scroll overlap is
   what a FAB is; end-of-scroll occlusion is a bug. */

/* BLUEPRINT rail: chrome sits on the stone surface (not card white), items in
   full ink, and the active state wears the redline — a 3px bar at the rail
   edge over the accent tint, never a filled dark pill. */
.navrail {
    /* The mockup's 230px carried longer labels than ours ("Kaimana Builders
       LLC" under the brand); with our short ones it left ~120px of dead space
       to the right of every row. 190px still clears the widest row by a wide
       margin — the deepest is a 48px-indented child like "Pipeline Board" —
       and hands the 40px back to the content. */
    width: 190px;
    flex-shrink: 0;
    background: var(--paper-warm);
    border-right: 1px solid var(--line);
    display: flex;
    flex-direction: column;
    gap: 1px;
    padding: 0 10px 12px;
    overflow-y: auto;
}

/* Brand block at the top of the rail (the original's .sidebar-brand). */
.navrail-brand,
.navrail a.navrail-brand {
    display: flex;
    align-items: center;
    justify-content: flex-start;   /* Blueprint: the brand sits left, like a mark */
    gap: 8px;
    min-height: var(--topbar-h);
    margin: 0 -10px 10px;
    padding: 10px 14px;
    border-bottom: 1px solid var(--line);
    border-radius: 0;
    background: var(--paper-warm);
    text-decoration: none;
}

.navrail a.navrail-brand:hover { background: var(--paper-warm); }

/* The top bar shows the brand at ≤1024px — the off-canvas rail showing it
   again would put the logo on screen twice. (This override must sit AFTER
   the base rule above: the selectors tie on specificity, so order decides.) */
@media (max-width: 1024px) {
    .navrail-brand,
    .navrail a.navrail-brand { display: none; }
    /* The brand block used to provide the top spacing — replace it. */
    .navrail { padding-top: 14px; }
}

/* The SHUT rail's brand. Default-hidden, because the OPEN rail is the state that wants
   the real wordmark and the container query below is what asks for this one — the same
   shape as everything else that folds at 110px. See SideNav.razor for why a second mark
   exists at all: a 32:9 logo in a 25px content box paints 7px tall. */
.navrail-brand-mark { display: none; }

/*  ─── A brand image that failed to load ────────────────────────────────────────
    `brand-img-failed` is put on the SLOT by the delegated error listener in
    App.razor (see the note there for why it is not an inline onerror=). It means
    the blob behind a stored logo could not be fetched, so every slot swaps to the
    mark it already renders rather than showing the browser's broken-image glyph.

    ⚠ (0,3,0) on the mark rule, so it beats BOTH the resting `display: none` above
    (0,1,0) and the container query's `.navrail .navrail-brand-mark` (0,2,0) — a
    failed image must show the mark on the OPEN rail too, where the container query
    is not in play at all. */
.navrail-brand.brand-img-failed .navrail-brand-full,
.topbar-logo.brand-img-failed .topbar-logo-img { display: none; }
.navrail-brand.brand-img-failed .navrail-brand-mark,
.topbar-logo.brand-img-failed .topbar-logo-mark { display: inline-flex; }

/*  The top bar's mark is fallback-only — it has no shut state to appear in, so
    unlike the rail's it is hidden until an image actually fails. */
.topbar-logo-mark { display: none; }

/*  ⚠ SAYS SO, rather than quietly looking fine. Only on the Company settings page:
    a sidebar should degrade silently, but the page that tells you what your branding
    is must not report a logo it cannot load as present. */
.logo-failed-note { display: none; }
/* The broken glyph goes too — the note replaces it rather than sitting beside it.
   Measured with both showing: an alt-text-and-torn-page icon next to red text reads
   as two faults, not one explained fault. */
.company-logo-slot.brand-img-failed .brand-img { display: none; }
.company-logo-slot.brand-img-failed .logo-failed-note {
    display: block;
    font-size: var(--fs-2xs);
    /* Fixed ink, not a theme token: the slot is deliberately #ffffff in BOTH faces
       (logos are authored for white), which is the same reason B6-10 pins the
       "No logo" text beside it. Measured 7.3:1 on that white. */
    color: #B00028;
}

/*  ⚠⚠ THE DARK SLOT NEEDS ITS OWN INK, AND THIS IS B6-10 ALL OVER AGAIN. The dark
    preview's background is the literal #1c1c1c in BOTH faces — it exists to show a
    dark-theme logo on the surface that logo was authored for — so it does not follow
    the palette and neither can text on it. #B00028 measured **2.33:1** there against
    7.3:1 on the white slot beside it: the same ink, one slot apart, passing in one and
    failing AA in the other. Reusing the night face's own warning coral instead. */
.company-logo-slot-dark.brand-img-failed .logo-failed-note { color: #FF7A6E; }

.navrail-brand-name {
    font-size: var(--fs-md);
    font-weight: 700;
    color: var(--navy);
    letter-spacing: -0.01em;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

/* Accordion group header: a clickable topic row (Blueprint: no caret — a
   click navigates to the group's main page; the open group carries the
   accent tint + redline exactly like an active link). */
.navrail-group {
    position: relative;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 8px;
    width: 100%;
    padding: 8px 10px;          /* same box as .navrail a — a ragged left edge
                                   between group headers and plain links reads
                                   as a bug in a 230px rail */
    border: none;
    border-radius: var(--r-sm);
    background: transparent;
    font-family: var(--font-sans);
    font-size: 13.5px;
    font-weight: 600;
    color: var(--navy);
    text-align: left;
    cursor: pointer;
    transition: background 0.12s;
}

/* ⚠⚠ THE :visited TWIN, and it became necessary the moment the header stopped being a
   <button>. `.navrail-group` is (0,1,0) and sets its own colour on its own background;
   `a, a:visited { color: var(--accent) }` is (0,1,1), so once you had actually been to
   /accounting the Money row's label repainted itself accent ON the row's own tint.
   Invisible to every tool: getComputedStyle is specified to report the UNVISITED style,
   and a headless profile has no history to make it happen. VisitedCascadeTests caught
   it — the twin is (0,2,0) and wins in both themes and any source order. */
.navrail-group,
.navrail-group:visited { color: var(--navy); }

.navrail-group:hover { background: var(--paper-warm); }

/* ⚠⚠ `.current`, NOT `.open` — the group that OWNS THE CURRENT PAGE wears the
   you-are-here treatment: accent tint, bold, redline bar, accent icon (the mockup's
   Projects row). These two were one class for as long as a group header NAVIGATED,
   because then the only group that could be open was the one you had just gone to.
   Now that a header only folds, `.open` would put this tint on whatever you last
   expanded and leave the page you are actually reading unmarked — the stranded tint
   the old comment here warned about, arriving from the opposite direction.
   ⚠ So: the TINT follows the page, the CHEVRON follows the fold (see
   `.navrail-group.open .navrail-caret`). Neither is decoration for the other, and a
   group can now legitimately be current-but-collapsed — which is exactly when this
   tint is the only thing left saying where you are.
   ⚠ `.navrail-grp.open` (the WRAPPER div, three characters apart) still drives the
   submenu's display and must stay keyed to the fold. */
.navrail-group.current { background: var(--sky-light); font-weight: 650; }
.navrail-group.current .nav-i { color: var(--sky); }
.navrail-group.current::before {
    content: "";
    position: absolute;
    left: -10px;
    top: 7px;
    bottom: 7px;
    width: 3px;
    border-radius: 3px;
    background: var(--sky);
}

/* Blueprint count pill: quiet mono number on an ink-tint chip, right edge. */
.nav-ct {
    margin-left: auto;
    font-family: var(--font-mono);
    font-size: 11px;
    font-weight: 600;
    color: var(--text-muted);
    background: rgba(28, 25, 23, 0.07);
    border-radius: var(--r-pill);
    padding: 3px 7px;   /* mockup .nav a .ct */
    flex: none;
}
[data-theme="dark"] .nav-ct { background: rgba(240, 237, 232, 0.1); }

/* THE DISCLOSURE SLOT — the rail's right-hand column, reserved on EVERY top-level
   row and filled only by the groups.

   A group header and a childless top-level link are deliberately the same weight
   (see `.navrail a.navrail-top`), so until this existed nothing said which rows had
   pages inside them; you found out by clicking. The chevron is SharedUI's `Fold`
   glyph and turn, unchanged — Fold's own header calls this accordion its twin, and
   `.section-caret` / `.acc-caret` are the same idiom again, so the rail was the one
   disclosure surface in the app without it.

   ⚠⚠ THE SLOT IS RESERVED ON ROWS WITH NO CHILDREN TOO, and that empty span is the
   whole point of the layout. Both the count pill and the chevron live at the right
   edge; give the chevron only to groups and a childless row's pill sits ~18px further
   right than a group's. Measured against the real nav, that is Leads' "3" out of line
   with Projects' "8" in a three-row window. Reserving the slot keeps two columns
   straight instead of trading one misalignment for another.
   ⚠ RIGHT EDGE, NEVER LEFT — Fold puts its caret before the title and this must not.
   The rail's icons all sit at x 29, shut and open, and a leading chevron would push
   every group's icon off that column and away from the childless rows'.
   ⚠ 14px inside a 17px icon row: the row height is DERIVED from the tallest inline
   item, so anything taller here silently re-grows the row and the icons start moving
   on expand again. Design/rail-hover-probe.html measures exactly that.

   The auto margin sits on whichever trailing element comes FIRST, so the pair stays
   glued to the right edge whether or not the row has a count.

   ⚠⚠ THE COLLAPSE PIN LANDS IN THIS COLUMN, on the FIRST row only. Measured at 1440px
   with the rail open: the key occupies x 217..239 / y 73..95 and the first row's slot
   x 213..227 / y 88..102 — a 10x8px overlap, and the pin has the higher z-index, so it
   would paint over a chevron there.
   It is harmless as the rail stands because the first row is always Home: it is written
   out by hand at the top of SideNav, ahead of everything the registry contributes, and
   it has no children, so that one slot is always the empty kind. That is a real
   coupling and not an observation — put a GROUP first, move `.navrail-pin`'s `top: 62px`,
   or change the brand block's height, and the key sits on a disclosure chevron. */
.navrail-caret {
    flex: none;
    display: flex;
    align-items: center;
    width: 14px;
    height: 14px;
    margin-left: auto;
    color: var(--glyph-ui);
}
.nav-ct + .navrail-caret { margin-left: 0; }
.navrail-caret svg { width: 14px; height: 14px; transition: transform 0.15s ease; }
/* Open = pointing down, and wearing the accent like the group's own icon does.
   ⚠ Keyed to `.navrail-grp` (the WRAPPER), not to the header itself: the header is a
   link and the fold state belongs to the group, so the wrapper is the one element that
   knows it. Three characters apart from `.navrail-group`; they are different elements. */
.navrail-grp.open .navrail-caret { color: var(--sky); }
.navrail-grp.open .navrail-caret svg { transform: rotate(90deg); }

/* ⚠⚠ THE HEADER ROW IS TWO CONTROLS SHARING ONE BOX. The link fills the row and does
   the navigating; the fold button is transparent, empty, and laid over the chevron the
   link paints. They must be SIBLINGS — a <button> inside an <a> is interactive content
   inside interactive content — so this wrapper exists purely to position one over the
   other. It is `relative` and nothing else; every visual still belongs to the link, so
   the row's tint, redline and hover are unchanged and undivided.
   ⚠ Later in the DOM is what puts the button on top for hit-testing. No z-index: adding
   one would lift it over the pin, which shares this corner on the first row. */
.navrail-ghead { position: relative; }
.navrail-fold {
    position: absolute;
    top: 0;
    bottom: 0;
    right: 0;
    /* The disclosure column (14px) plus the row's 10px padding, plus a little slop —
       reaching x 209, which clears the widest count pill's right edge at 205. Widen it
       and it starts eating the pill; narrow it and the chevron gets a 14px target. */
    width: 28px;
    padding: 0;
    border: 0;
    background: transparent;
    cursor: pointer;
    appearance: none;
}
.navrail-fold:focus-visible { outline: 2px solid var(--sky); outline-offset: -2px; border-radius: var(--r-sm); }

/* ⚠⚠ THE HEADER BECAME AN <a>, WHICH PUTS IT UNDER `.navrail a`. That rule is (0,1,1)
   and `.navrail-group` is (0,1,0), so `.navrail a { font-weight: 500 }` silently won and
   group headers went light — undoing the deliberate rule that a group header and a
   childless top-level link carry the SAME weight, so neither reads as a child of the
   other. Re-asserted at (0,2,1) and (0,3,1).
   ⚠ The gap is deliberately NOT re-asserted: `.navrail a` brings 9px where
   `.navrail-group` said 8px, and 9px is what every other row in the rail already uses,
   so inheriting it makes the icon-to-label spacing uniform for the first time. */
.navrail a.navrail-group { font-weight: 600; }
.navrail a.navrail-group.current { font-weight: 650; }

/* Children read a register QUIETER than groups: smaller, muted, tight. */
.navrail-children {
    display: flex;
    flex-direction: column;
    gap: 0;
    padding: 1px 0 5px;
}

/* .navrail beats the later `.navrail a { padding: 7px 12px }` on specificity
   (0,2,1 vs 0,1,1) — without it the general rule silently killed the indent
   and children rendered flush-left, outside their group. */
.navrail .navrail-children a {
    /* Children step IN past the group label (label text sits at ~38px):
       one clear extra indent so the nesting is unmistakable. */
    padding: 5.5px 10px 5.5px 48px;
    font-size: var(--fs-sm);
    font-weight: 500;
    color: var(--text-muted);
}
.navrail .navrail-children a:hover { color: var(--navy); }

/* Mockup .nav a — 13.5px, 8px/10px box. (The mockup's 520 needs a variable
   font; our Inter ships discrete weights, where 520 rounds UP to 600 and
   erases the contrast with the 600-weight top-level links — so 500 it is.
   The 1px row gap comes from .navrail's own `gap`, not a margin.) */
.navrail a {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 8px 10px;
    border-radius: var(--r-sm);
    font-size: 13.5px;
    font-weight: 500;
    color: var(--navy);
    text-decoration: none;
    position: relative;
    transition: background 0.12s, color 0.12s;
}

.navrail a:hover {
    background: var(--paper-warm);
    color: var(--navy);
}

/* Childless top-level links (Dashboard, Employees, Portal) carry the same
   weight as the accordion group headers, so they never read as children of
   whichever group happens to be expanded above them. */
.navrail a.navrail-top {
    padding: 8px 10px;   /* mockup .nav a */
    font-weight: 600;
    color: var(--navy);
}

/* Two stacked lines in a table cell (email over phone, date over author). */
.stack2 { display: flex; flex-direction: column; gap: 1px; }

/* Compact on/off toggle (Users team-member flag). Hides the native checkbox and
   draws a pill track; :checked slides and tints the knob with the accent. */
.switch { display: inline-flex; cursor: pointer; }
.switch input { position: absolute; opacity: 0; width: 0; height: 0; }
.switch-track {
    position: relative; width: 36px; height: 20px; border-radius: 999px;
    background: var(--line); transition: background 0.15s;
}
.switch-track::after {
    content: ""; position: absolute; top: 2px; left: 2px;
    width: 16px; height: 16px; border-radius: 50%;
    background: var(--card); box-shadow: 0 1px 2px rgba(0,0,0,0.25);
    transition: transform 0.15s;
}
.switch input:checked + .switch-track { background: var(--sky); }
.switch input:checked + .switch-track::after { transform: translateX(16px); }
.switch input:focus-visible + .switch-track { outline: 2px solid var(--sky); outline-offset: 2px; }

.navrail a.active {
    background: var(--sky-light);
    color: var(--navy);
    font-weight: 650;
}

/* The redline: a 3px accent bar at the rail's edge marks the active page
   (Blueprint) — works unchanged in dark, where the tint and bar lighten. */
.navrail a.active::before {
    content: "";
    position: absolute;
    left: -10px;               /* the rail's horizontal padding */
    top: 7px;
    bottom: 7px;
    width: 3px;
    border-radius: 3px;
    background: var(--sky);
}

/* Child links mark active with the small redline DOT (Blueprint's on2), not
   the full edge bar — parent vs child active states stay distinguishable.
   Specificity (0,3,1) so no general rail rule can out-cascade these. */
.navrail .navrail-children a.active { background: rgba(28, 25, 23, 0.07); color: var(--navy); font-weight: 650; }
[data-theme="dark"] .navrail .navrail-children a.active { background: rgba(240, 237, 232, 0.1); }
.navrail .navrail-children a.active::before {
    content: "";
    position: absolute;
    left: 36px;               /* just left of the 48px-indented child text */
    top: 50%;
    bottom: auto;
    width: 4px;
    height: 4px;
    margin-top: -2px;
    border-radius: 50%;
    background: var(--sky);
}

.navrail-backdrop { display: none; }

/* ═══ FLOATING AUTO-COLLAPSING RAIL — desktop only ═══════════════════════
   The rail stops being a wall at the window's edge and becomes a PANEL resting
   on the page: inset on all four sides, radiused, elevated, with the ground
   visible around it.

   ⚠⚠ THE ICON RAIL IS THE RESTING STATE, NOT A DEGRADED ONE. It opens on hover
   and shuts on leave; the key on its edge pins it. That buys back 168px of
   content width on every dense table page, which is roughly one more column.

   ⚠ TWO DIFFERENT BEHAVIOURS, DELIBERATELY:
     · a hover-peek OVERLAYS — the rail is absolutely positioned, so expanding
       does not move the table. Shoving a 12-column grid sideways every time the
       pointer crosses the rail is violent.
     · a PIN takes real space (`:has(.pinned)` widens the row's padding), because
       pinning is a decision to keep it open and content should get its width
       back. A pin that permanently covered the job names would be worse than no
       pin at all.

   ⚠ ALL OF THIS IS ≥1025px. Below that the rail is the existing off-canvas
   overlay driven by NavRailState and the burger, and none of it changes.

   ⚠ NO JAVASCRIPT. The open/shut is `:hover`/`:focus-within`; the asymmetric
   timing is one transition-delay in the base rule (the shut delay) and another
   on hover (the open delay). Opening instantly makes the rail twitch when the
   pointer is merely passing through; shutting instantly punishes a two-pixel
   overshoot. 110ms in, 260ms out. */
@media (min-width: 1025px) {
    .app-shell-rail {
        position: relative;
        padding-left: calc(var(--rail-shut) + var(--rail-gap) * 2);
        transition: padding-left var(--rail-dur) var(--rail-ease);
    }
    .app-shell-rail:has(.navrail.pinned) {
        padding-left: calc(var(--rail-open) + var(--rail-gap) * 2);
    }

    .navrail {
        position: absolute;
        left: var(--rail-gap);
        /* ⚠ TOP-ALIGNED WITH THE TOOLBAR, and it OVERLAYS the toolbar when it
           opens. An earlier attempt started the rail BELOW the bar so it could
           never cross the search — that removed the collision and broke the
           composition: it left a dead corner where the brand had been and cut
           the toolbar adrift from the rail.
           The rail and the toolbar are now two panels resting on the same ground,
           starting on the same line. The rail simply sits ABOVE the toolbar in
           the stack (z-index over .topbar's 100), so opening it covers the
           toolbar's left end for as long as you are pointing at the rail. That is
           what an overlay is for, and the toolbar returns the instant you leave. */
        top: var(--rail-gap);
        bottom: var(--rail-gap);
        width: var(--rail-shut);
        /* ⚠ A SIZE CONTAINER, so the rows can ask how wide the panel actually is —
           see the text-fold block below, where that question is the only thing that
           can keep a label from arriving before the panel does. Safe here because
           this rail's width is always explicit (--rail-shut / --rail-open / the
           transition between them), never content-derived, which is the one thing
           `inline-size` containment forbids. Layout containment does not clip, so
           the pin keeps its overhang. */
        container: navrail / inline-size;
        /* Above .topbar (100) — see the top-alignment note above. */
        z-index: 120;
        padding: 0 8px 10px;
        border: 1px solid var(--line);
        border-radius: 14px;
        box-shadow: 0 10px 30px rgba(28, 25, 23, 0.10), 0 2px 6px rgba(28, 25, 23, 0.05);
        /* ⚠ visible while SHUT so a flyout can escape the panel; auto while OPEN
           so a long rail scrolls. The flyout only exists in the shut state, so
           the two never need to be true at once. */
        overflow: visible;
        /* ⚠⚠ THE OVERFLOW FLIP RIDES THE SAME CLOCK AS THE WIDTH, and it must.
           `overflow` is a DISCRETE property, so with no transition on it the open
           ruleset's value landed on the FIRST hovered frame while the panel was
           still 68px wide. That one-frame flip is not cosmetic: it is the entire
           cause of the pin flicker (see `.navrail-pin` below — clipping the panel
           destroys the very hover target that opened it) and of the 5px horizontal
           scrollbar that appeared under the early labels.
           `allow-discrete` puts it on --rail-in / --rail-out with everything else;
           the single `transition-delay` in the open rule below repeats across all
           three entries in this list. */
        transition: width var(--rail-dur) var(--rail-ease) var(--rail-out),
                    overflow-x 0s var(--rail-out) allow-discrete,
                    overflow-y 0s var(--rail-out) allow-discrete;
    }
    .navrail:hover,
    .navrail:focus-within,
    .navrail.pinned {
        width: var(--rail-open);
        overflow-y: auto;
        /* ⚠ `clip` — SPELLED OUT, not left to the `visible` that silently computed
           to `auto`. A long rail still scrolls vertically; the horizontal axis can
           no longer produce a scrollbar, and it did produce one: measured at 1440px,
           112px of labels inside the 66px content box cost 5px of the rail's height
           to a horizontal scrollbar for the length of the open delay. `clip` is not
           a scroll container either, so the phantom 11px of scrollWidth this panel
           used to carry can no longer be scrolled to by script or by a stray
           shift-wheel. Clipping itself is unchanged — anything outside the padding
           box is still cut, which is what the two redline overrides above exist for. */
        overflow-x: clip;
        transition-delay: var(--rail-in);
    }

    /* ⚠⚠ RESTORING A SAVED PIN MUST NOT LOOK LIKE THE RAIL OPENING ITSELF. The pin is
       remembered per device, and it can only be read back through JS interop — which
       lands one render AFTER the rail first paints. Applying `.pinned` at that moment
       starts the width transition, and the pinned rule's delay is --rail-in, so the
       menu would sit shut for half a second and then slide open on its own while the
       page was already usable. That reads as a glitch, not as a restored preference.
       SideNav carries this class for exactly the two renders the restore takes, so the
       rail is simply ALREADY WIDE on the frame it first appears.
       ⚠ The shell's padding is animated too, on a different element, so `:has` reaches
       it — without that the content column slides sideways on its own instead.
       ⚠ Must stay BELOW the rules it cancels: it ties with `.navrail` on specificity
       (0,1,0), so document order is what decides. */
    .navrail.navrail-noanim,
    .app-shell-rail:has(.navrail-noanim) { transition: none; }

    [data-theme="dark"] .navrail {
        box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45), 0 2px 6px rgba(0, 0, 0, 0.30);
    }

    /* ⚠⚠ THE BRAND BLOCK WAS WIDER THAN THE PANEL IT SITS IN. Its `margin: 0 -10px`
       was written for the BASE rail's `padding: 0 10px`, where the two cancel and
       give a clean full-bleed band. THIS rail has `padding: 0 8px`, so -10px made the
       block 4px wider than the content box — 2px wider than the panel itself — and
       the shut rail is `overflow: visible`, so nothing clipped it. Measured: a 70px
       brand inside a 68px rail, spilling 1px past each side.
       And `border-radius: 0` + an opaque `--paper-warm` background sat on top of the
       panel's rounded top corners and filled them in, which is most of what "the logo
       sticks out and breaks the sidebar" actually was — the block, not the image
       (the 30px mark measured comfortably inside the rail all along).
       ⚠ 13px, not 14: the panel's 14px outer radius minus its 1px border is the
       radius the INNER edge actually follows, and the brand spans the padding box.
       ⚠ Same 10-vs-8 drift as the two redlines below — all three are fixed now, and
       if this rail's padding ever changes again, ALL THREE move with it. */
    .navrail a.navrail-brand {
        margin-left: -8px;
        margin-right: -8px;
        border-radius: 13px 13px 0 0;
    }

    /* ⚠⚠ THE LAST TWO OF THE 10-vs-8 DRIFT. Both bars are `left: -10px` in the base
       section — written against the BASE rail's `padding: 0 10px`, where the bar lands
       flush on the panel edge. This rail pays 8px, so 2px of each 3px bar fell outside
       the padding box, and the two states failed DIFFERENTLY, which is why it read as
       two unrelated glitches. Measured at 1440px before this fix:

         SHUT  overflow-x visible -> bar painted in full but 2px OVERHANGS the panel
         OPEN  overflow-x auto    -> that overhang is CLIPPED: 1px visible of 3px

       (The rail sets only `overflow-y: auto` when open; per spec a `visible` on the
       other axis then computes to `auto`. Same clause that cut the pin in half.)
       ⚠ The BASE rule is deliberately left at -10px: below 1025px the off-canvas rail
       really does pay 10px, so -10px is correct there. This is an override, not a
       correction.
       ⚠ Ties on specificity with the base rules ((0,2,1) and (0,2,2)), so ORDER
       decides — this must stay BELOW them, exactly like the brand override above.
       ⚠ `.navrail .navrail-children a.active::before` is NOT in this list and must not
       be added: it is (0,3,1), it beats this, and its `left: 36px` is the child dot's
       indent next to 48px-indented text — a different thing that only looks similar.
       Verified unchanged at 4px wide, 0px outside the panel, in both states.
       ⚠ `.current`, not `.open` — the group header's redline moved with the rest of
       the you-are-here treatment when group clicks stopped navigating. Same bar, same
       arithmetic; only the class that carries it changed. */
    .navrail-group.current::before,
    .navrail a.active::before {
        left: -8px;
    }

    /* ⚠⚠ THE TEXT WAITS FOR THE PANEL — this pair is what puts it on the clock.
       `display` is discrete, so with no transition the fold rule below simply
       STOPPED MATCHING on the first hovered frame: every label, count pill, group
       caption and submenu appeared at once inside a rail that was still 68px wide
       and would not move for another --rail-in. Measured at 1440px on the frame the
       pointer arrives:

         width 68px unchanged   ·   content 112px in a 66px content box
         rows 33px -> 36/39px   ·   5px of horizontal scrollbar

       — text jammed against the panel edge, rows changing height, a scrollbar
       flashing, all of it BEFORE the rail so much as started to open. That is the
       "the text expands before the bar does" complaint, and it was never a wrong
       duration: the labels were not on a clock at all, so raising --rail-in only
       made them sit there longer.
       `transition: display 0s <delay> allow-discrete` keeps the flip instant and
       moves WHEN it happens to the panel's own two variables — in at --rail-in, out
       at --rail-out, exactly with the width. Nothing here fades; a label is still
       either in the a11y tree or out of it, which is the whole point of the
       `display: none` below.
       ⚠⚠ AND THE TWO DIRECTIONS NEED DIFFERENT MECHANISMS — this is the trap, and
       it costs an afternoon to find by eye. ON THE WAY IN, A `transition-delay` ON
       `display` DOES NOTHING. A transition fills BACKWARDS through its delay, and
       `display` transitioning FROM `none` is specified to take the visible value at
       0% so the element can be seen for the whole animation; together that makes
       the label visible for the entire delay. Measured with the delay correctly
       resolved to 0.55s, `display` was already `block` at open+0ms — a longer delay
       makes it worse, not better, which is exactly the symptom that started this.
       So the way in is expressed as what it actually is: a question about ROOM, not
       about time. The rail is a size container (see `container:` above) and the
       text is folded away while the panel is NARROW. The width transition already
       carries --rail-in, so the labels arrive when the panel physically grows past
       them and cannot get ahead of it by construction — retune --rail-in, or
       --rail-dur, or drop the delay entirely, and this stays correct with no second
       number to keep in step.
       ⚠ The way OUT is the direction where a delay does work (`display` going TO
       `none` holds the visible value until 100%), so that half stays a transition.
       It is scoped to the RESTING rule on purpose: a transition takes its timing
       from the AFTER-CHANGE style, so declaring it unscoped would also create one
       on the way in — and an in-flight transition outranks every author rule,
       `!important` included, so it would silently beat the container query above.
       ⚠ `.navrail-children` is in these lists and is safe to be: SideNav renders the
       submenu only for the OPEN group, so switching groups adds and removes the
       element rather than restyling it, and a fresh element does not transition. */
    @container navrail (max-width: 110px) {
        /* ⚠ `.navrail-caret` folds with the text, not with the icons: it is a marker
           ABOUT the label, and a 68px panel has no room for anything beside a 17px
           glyph — left in, it would land on the icon exactly as the old pin did. */
        /* ⚠⚠ `.navrail-fold` FOLDS AWAY TOO, AND NOT FOR TIDINESS. It is a 28px hit area
           pinned to the row's right edge; on a 68px panel the row's content box is 50px,
           so it would cover x 41..69 while the icon sits at 29..46 — it would swallow
           the right half of every group's icon and toggle instead of navigating. The
           shut rail has one target per row, and it is the row. */
        /* ⚠ `.navrail-brand-full` folds with the TEXT, not with the icons, and for the
           same reason the labels do: there is no room for it. A 32:9 wordmark held to
           this panel's 25px content box paints 7px tall in a 30px box — legible as a
           smudge, and read by the person looking at it as a broken image rather than a
           small one. `.navrail-brand-mark` takes its place below. */
        .navrail :is(.nav-lb, .navrail-brand-name, .nav-ct, .navrail-glabel-t, .navrail-caret, .navrail-fold, .navrail-brand-full) { display: none; }
        /* (0,2,0), to out-specify the (0,1,0) resting `display: none` further up. The
           mark is `flex-shrink: 0` at 30px, so it keeps its full height in a 25px content
           box rather than being squeezed the way the wordmark was. */
        .navrail .navrail-brand-mark { display: inline-flex; }
        /* (0,4,0), because it has to out-specify `.navrail-grp.open > .navrail-children`
           (0,3,0) in the show/hide pair further down. */
        .navrail .navrail-grp.open > .navrail-children { display: none; }
        /* ⚠ AND THE PIN, which is why it needs no timing of its own — see the long
           note on `.navrail-pin` below. A 22px key does not fit a 68px panel without
           landing on an icon, so the narrow panel simply has no key. */
        .navrail .navrail-pin { display: none; }
    }
    /* ⚠ 110px is a "there is not room for text" threshold, NOT a copy of --rail-shut.
       ⚠⚠ AND IT IS A CONTENT-BOX WIDTH: an `inline-size` container is queried on its
       content box, which for this rail is the panel minus 18px (1px border + 8px
       padding, twice). So 110 here is a 128px PANEL. At the current --rail-shut (55px)
       and --rail-open (190px) the panel reads as 37px and 172px, and any value between
       those two behaves the same; the lower it sits, the earlier the growing edge
       reveals the text.
       ⚠⚠ IT IS A LITERAL SITTING NEXT TO VARIABLES, because a container condition
       cannot read a custom property — so retuning the rail does NOT carry it along, and
       it has already been left behind once (68/236 -> 55/190 with both thresholds
       untouched; they stayed valid by luck, not by design). RailContainerThresholdTests
       pins both inside the rail's own two widths and prints the arithmetic when they
       fall out. See the centring block below, where reading one of these as a PANEL
       width rather than a content-box width was a real, shipped bug. */
    /* ⚠⚠ `.navrail-pin` IS DELIBERATELY NOT IN THIS LIST, and adding it to make the
       key leave at the same instant as the text actively breaks it. The pin has no
       resting `display` rule — the container query is its ONLY hide — so at un-hover
       nothing about it changes yet (the panel is still 236px) and no transition is
       created. The flip comes later, when the shrinking panel drops under 110px, and
       a --rail-out delay attached to THAT pushes the key out to ~330ms: past the end
       of the collapse, leaving a key sitting on a fully shut 68px panel. Measured.
       The ~50ms by which the key outlives the labels is the panel narrowing past it,
       which is the honest reading. */
    .navrail:not(:hover):not(:focus-within):not(.pinned) :is(.nav-lb, .navrail-brand-name, .nav-ct, .navrail-children, .navrail-glabel-t) {
        transition: display 0s var(--rail-out) allow-discrete;
    }

    /* ⚠⚠ AN ICON MUST NOT MOVE WHEN THE PANEL OPENS. Someone aiming at a 17px glyph
       on the shut rail has already committed to a target by the time the panel
       starts to widen; if the row grows underneath them, the thing they are pressing
       slides out from under the pointer. That is a click landing on the wrong page,
       not a cosmetic wobble, and it gets worse the further down the rail you are
       because every row's growth pushes the next one along.

       The cause is that the ROW BOX is sized by its tallest inline item, and while
       the rail is shut the only item left is the 17px icon. Measured at 1440px, icon
       centres, shut -> open:

         Home     94.5 ->  96.1   (+1.6)   row 33 -> 36.3
         Leads   128.5 -> 134.5   (+6.0)   row 33 -> 38.5   <- count pill, the worst row
         Projects 162.5 -> 172.3  (+9.8)   row 33 -> 35
         Settings 582.5 -> 580.9  (-1.6)   bottom-anchored, so it moves the other way

       Two things overflow the icon: the label's line box (13.5px text inheriting the
       body's 1.5 line-height = 20.25px) and the count pill (11px text on the same
       1.5, plus 3px of padding each side = 22.5px). Pinning both to `line-height: 1`
       makes them 13.5px and 17px — the pill exactly matching the icon — so the icon
       is the tallest item in EVERY state and the row is a constant 33px. Verified:
       every icon centre above is identical shut and open afterwards.

       ⚠ NOT a `min-height` on the row: that fixes the box while leaving the pill
       taller than the content area it sits in, so the pill grows the row right back
       (or bursts out of it) the moment anything about the type changes. The row
       height here is DERIVED — icon plus padding — and stays derived.
       ⚠ Deliberately NOT applied to `.navrail-children a`, which carry no icon and
       are never on screen while the rail is shut, so they cannot move anything and
       keep the roomier 1.5 the submenu is set in.
       ⚠ This does not touch the submenu's own arrival — see the note below the fold
       rules, which is a separate and much larger shift. */
    .navrail a.navrail-top .nav-lb,
    .navrail .navrail-glabel-t,
    .navrail .nav-ct {
        line-height: 1;
    }

    /* ⚠⚠ AND NO LABEL MAY REFLOW WHILE THE PANEL IS GROWING. This is the vertical
       bounce: a label that wraps is a row that gets TALLER, and a row that gets taller
       in the middle of the animation shoves everything below it down and then hauls it
       back when the text unwraps. Measured at 1440px, the Money row's icon through the
       expand:

         t=560ms  panel 121px   y 196.5   (submenu still folded)
         t=566ms  panel 148px   y 313.5   <- overshoot
         t=575ms  panel 180px   y 294     <- springs back 19.5px

       19.5px is one line. The culprit is the deepest child label — at a 48px indent
       "Change Orders" needs 93.1px and the growing panel offers it 72.1px, so it wraps
       to two lines for about 15ms and then unwraps. The submenu measured 117px, then
       97.5px.

       `nowrap` makes the text OVERFLOW instead, and the panel's `overflow-x: clip`
       cuts it at the edge — so a long label is revealed by the widening panel rather
       than reflowing inside it. That is what a drawer should look like anyway.
       ⚠ Desktop only. Below 1025px the rail is a fixed 190px off-canvas panel that
       never animates, so wrapping there costs nothing and is the better behaviour for
       a long label — and the base rail has no `overflow-x: clip` to catch the overflow
       that `nowrap` would create.
       ⚠ `.navrail-brand-name` is absent because it already carries nowrap (plus an
       ellipsis) in the base rules, for the same reason arrived at separately. */
    .navrail .nav-lb,
    .navrail .navrail-glabel-t {
        white-space: nowrap;
    }

    /* Everything that is text folds away while shut. display:none rather than
       opacity, so a hidden label is out of the tab order and off the a11y tree. */
    .navrail:not(:hover):not(:focus-within):not(.pinned) .nav-lb,
    .navrail:not(:hover):not(:focus-within):not(.pinned) .navrail-brand-name,
    .navrail:not(:hover):not(:focus-within):not(.pinned) .nav-ct,
    /* ⚠ The open group's SUBMENU folds away too, and it was missed. Every child
       is a label and nothing else, so with `.nav-lb` hidden the shut rail kept
       eight unlabeled 11px strips — 88px of dead space — and the ACTIVE child
       went on painting its background and its ::before dot at a 48px indent.
       That is the stray grey pill with a blue dot that appeared under the open
       group's icon in the 68px rail: not a component, just a highlight with
       nothing left to highlight.
       Specificity (0,5,0) — `:not()` counts its argument — so this beats the
       `.navrail-grp.open > .navrail-children` rule below (0,3,0) while shut, and
       loses to nothing else. :focus-within keeps it reachable by keyboard. */
    .navrail:not(:hover):not(:focus-within):not(.pinned) .navrail-children,
    .navrail:not(:hover):not(:focus-within):not(.pinned) .navrail-glabel-t {
        display: none;
    }
    /* ⚠⚠ THE ROW CENTRING IS THE OTHER HALF OF THE SHUT STATE, AND IT IS A QUESTION
       ABOUT ROOM, NOT ABOUT TIME. A row holding a lone icon is centred, and its own
       side padding is dropped so the centring has the full 50px to work in.

       ⚠⚠ THIS USED TO BE A TIMED FLIP AND IT WAS THE COLLAPSE FLASH. Driven off
       `:not(:hover)` with a --rail-out delay, the centring landed the instant the
       rail committed to closing — while the panel was still 231px wide — so every
       icon centred itself in a WIDE panel and then rode the shrinking edge back
       home. Measured at 1440px, icon left edges through the collapse:

         t=129ms  width 236   Home 29     Leads 29
         t=131ms  width 231   Home 90.2   Leads 116.8   <- 61-88px sideways, one frame
         t=150ms  width 140   Home 56.4   Leads 71.7
         t=214ms  width  68   Home 29.3   Leads 35.5

       Every icon in the rail sweeping 60-90px and back inside 85ms is what reads as
       the panel flashing. The same flip on the way IN is harmless and always was,
       which is why only the collapse showed it: opening applies it at 68 -> 73px,
       where the identical rule moves an icon 6.5px.

       Keyed to the container instead, the centring engages only while the panel is
       actually at its narrowest. The icons hold their open-state x for the whole
       collapse and settle in the last frame or two, and the sweep cannot happen in
       either direction because there is no longer a moment where a narrow-panel rule
       is applied to a wide panel.

       ⚠⚠ 52px IS A CONTENT-BOX WIDTH, NOT THE PANEL'S. An `inline-size` container is
       queried on its CONTENT box, so for this rail every threshold here is the border
       box minus 18px (1px border + 8px padding, twice). At the current --rail-shut of
       55px the panel reads as 37px, so 52 sits comfortably above the resting width and
       below the open one. Written as the panel's own 70px it looked right and measured
       wrong — 70 content is an 88px panel, so the centring switched back on while the
       rail was still 81px and dragged every icon 13px sideways in the last third of the
       collapse. That is the same bug as the one this rule replaced, just smaller.
       ⚠ RailContainerThresholdTests keeps this and the 110px one inside --rail-shut and
       --rail-open, because neither can be written as a variable and both were already
       left behind once when the rail was retuned by hand.
       ⚠ This threshold means "the panel is at its resting width"; the 110px one above
       means "there is not room for text". Different questions, different numbers —
       collapsing them to one would centre the icons in a 128px panel and bring the
       full sweep straight back. Both are literals because a container condition cannot
       read a custom property, so BOTH move if --rail-shut or the rail's padding does.
       ⚠ The ordering is deliberate: centring releases first, text arrives later. The
       icons reach their final x BEFORE any label appears.
       ⚠ THE SELECTORS CARRY THEIR OWN WEIGHT and cannot be simplified to
       `.navrail .navrail-top`. A container query adds NO specificity, and the padding
       being overridden here comes from `.navrail a.navrail-top { padding: 8px 10px }`
       and `.navrail a.navrail-brand { padding: 10px 14px }` — both (0,2,1). The old
       rule won on the four classes in its `:not()` chain (0,5,0); these have to tie at
       (0,2,1) and win on document order instead, which is why they must stay below
       those base rules. `.navrail-group`'s base is only (0,1,0), so (0,2,0) is enough
       there. */
    @container navrail (max-width: 52px) {
        .navrail a.navrail-top,
        .navrail .navrail-group {
            justify-content: center;
            padding-left: 0;
            padding-right: 0;
        }
        /* ⚠⚠ AND THE ROW IS NARROWED TO EXACTLY THE WIDTH THAT LANDS THE CENTRED ICON
           ON THE OPEN STATE'S INDENT. Without it the two positions are 6.5px apart and
           nothing can close that: a row centred in the 50px panel puts its icon at
           x 35.5, while the open rail puts it at the rail's content edge plus the row's
           own 10px padding, x 29. One of them has to move, and the icon is the thing
           being aimed at.
           Centring inside a box of `icon + padding either side` makes the two the same
           position by construction: (37 - 17) / 2 = 10, the padding that is dropped
           above. Change `.navrail a`'s 10px or the 17px glyph and this follows them.

           ⚠ THE ROW, NOT THE ICON: `margin: 0 auto` on the glyph would move the icon
           and leave the row's hover/active background full-width and centred, so the
           highlight would sit 6.5px off its own icon. This keeps the pill and the icon
           on the same footing.
           ⚠ NARROW-STATE ONLY — this replaced an inline `style="width: 75%"` in
           SideNav.razor which, being inline, also applied to the OPEN rail and cut
           every highlight pill there to three-quarters of the panel. 75% of 50px is
           37.5px, which is this arithmetic to within a quarter-pixel; it was the right
           idea reached by eye. Scoped here it costs the open rail nothing.
           ⚠⚠ THE BRAND IS LEFT OUT OF BOTH RULES ABOVE, and that IS its fix. Its mark
           is 30px inside a full-bleed band that spans the panel's padding box via
           `margin: 0 -8px`, so `icon + padding either side` comes to 58px and does not
           fit the 50px panel — the sum that works for a 17px glyph has no solution
           here. But it does not need one: leave the band's own `padding: 10px 14px`
           alone and the mark stays at x 25 in BOTH states, which is stationary by
           doing nothing. Centring it, as this rule used to, moved it 25 -> 29.
           Measured; it was the last thing in the rail still travelling. */
        .navrail a.navrail-top,
        .navrail .navrail-group {
            width: calc(17px + 10px * 2);
        }
    }

    /* ⚠⚠ THERE IS DELIBERATELY NO TOOLTIP AND NO FLYOUT HERE, and that is a
       design decision rather than an omission — do not "restore" them.

       Both were built, and both were UNREACHABLE BY CONSTRUCTION. The rail
       expands on hover, and CSS :hover fires the instant the pointer enters —
       only the width TRANSITION is delayed, not the state. So the moment you
       reach any item, `.navrail:not(:hover)` is already false, the rail is
       opening and the real labels are on their way in. A tooltip could never
       paint, and a flyout would be a second copy of children that are about to
       appear inline two hundred milliseconds later.

       The reference designs this came from carry tooltips and flyouts because
       their rails collapse MANUALLY — a shut rail that stays shut while you
       point at it genuinely needs a way to read and reach its contents. Asking
       for auto-collapse removes that need and those affordances with it. Keeping
       them would have been dead CSS wearing the costume of a feature. */
    .navrail-grp { position: relative; }
    /* ⚠⚠ `flex`, NOT `block` — and this pair is a layout declaration, not a
       visibility toggle. `.navrail-children` is a `flex-direction: column` list,
       and a flex parent BLOCKIFIES its children: `.navrail a` is `inline-flex`
       (see the rail-icon block), which computes to `flex` and stacks only while
       the parent is a flex container. Writing `block` here as "the opposite of
       none" silently handed those links back to normal flow, so they became
       inline-level boxes and FLOWED ALONG A LINE — measured at 1440px with
       Projects open: "All jobs" and "Dispatch" sharing a row, "Timeline" alone,
       "Plans" and "Safety" sharing the next. Eight links, six rows.
       ⚠ Invisible below 1025px: this block is desktop-only and the base rule
       keeps the submenu stacked on a phone. Pinned by FlexColumnIntegrityTests. */
    .navrail-grp > .navrail-children { display: none; }
    .navrail-grp.open > .navrail-children { display: flex; }

    /* THE PIN — a round key inside the panel's right edge, and ONLY while the panel
       is open.

       ⚠⚠ THIS WAS THE MOST INTRICATE CORNER OF THE RAIL, and every bit of that came
       from ONE decision that is now reversed: showing the key on the SHUT 68px panel.
       There is no room for a 22px key inside 68px — measured, it sat at x 39..61 over
       icons centred at x 26..43, covering the top-right of the Home icon and taking
       its clicks. So it was pushed OUT to `right: -11px` and slid back in as the panel
       opened, and that overhang then caused, in order:

         · being cut in half whenever the rail opened — the open state clips to the
           PADDING BOX and a negative offset is measured from exactly there, which
           also left 11px of phantom horizontal scroll (scrollWidth 245 vs clientWidth
           234). Padding cannot rescue it: padding moves the box the offset is
           measured from.
         · being UNCLICKABLE in that state rather than merely clipped — hit-testing
           the outer half returned the page behind the rail.
         · and finally the flicker. The overhang is outside the panel, so pointing at
           the key hovered the RAIL, and the rail's open state clipped away the very
           hover target that had produced it. Open, shut, open, shut, once per frame.
           An oscillation has no duration to slow down, which is why retuning
           --rail-in never touched it.

       Not drawing the key on a shut panel removes the overhang, and the overhang was
       the cause of all three. What is left is a key at `right: 6px`, always, with no
       slide, no `pointer-events` withdrawal and no transition of its own.
       ⚠ DO NOT REINSTATE THE SHUT-STATE KEY. If a collapsed affordance is wanted
       again it needs somewhere that is not over an icon, and the shut rail has no
       such place at 68px — that is the constraint that started all of the above.

       ⚠ It is hidden by THE SAME CONTAINER QUERY AS THE LABELS, not by a
       `:not(:hover)` rule, and that is what gives it the right timing for nothing: it
       appears once the panel has physically grown past the text and goes when the
       panel shrinks back under it, in both directions, with no delay to keep in step.
       ⚠ A keyboard user reaches it unchanged — Tab lands on the brand link first,
       `:focus-within` opens the rail, and the key is in the tab order by the time the
       next Tab is pressed.
       ⚠ And pinning cannot trap anyone: `.pinned` holds the rail open, so the key is
       on screen for as long as it is pinned. */
    .navrail-pin {
        position: absolute;
        top: 62px;
        right: 6px;
        width: 22px;
        height: 22px;
        border-radius: 50%;
        border: 1px solid var(--line);
        background: var(--card);
        color: var(--text-muted);
        display: grid;
        place-items: center;
        cursor: pointer;
        padding: 0;
        z-index: 80;
        box-shadow: 0 1px 4px rgba(28, 25, 23, 0.14);
    }
    .navrail-pin:hover { background: var(--fill); color: var(--navy); }
    .navrail-pin svg { width: 15px; height: 15px; margin-top: 2px; }
    /* ⚠ A STATIONARY THUMBTACK. It does not rotate, and it must not be given a
       direction again. The glyph was a chevron flipped 180° between states, which
       answered "which way will this move the panel?" — a question the panel answers
       by itself, on a key that is now only ever seen on an OPEN panel, where there is
       only one direction left to offer. What the key reports is a STATE, and the
       state is the fill: outlined = not pinned, solid accent = pinned. The
       title/aria-label carry what the next press does. */
    .navrail.pinned .navrail-pin { background: var(--accent); border-color: var(--accent); color: #fff; }

    /* THE TOOLBAR IS A PANEL TOO. Once the rail floats, a full-bleed bar butted
       against it reads as a leftover from the old layout — one object resting on
       the ground beside one object welded to the window. Same inset, same radius,
       same hairline, same elevation, and its top edge on the same line as the
       rail's, so the two are obviously the same kind of thing.
       ⚠ Scoped to .topbar-app: the control plane keeps the plain full-bleed bar,
       because it has no floating rail for one to belong to. */
    .app-shell-rail .topbar-app {
        margin: var(--rail-gap) var(--rail-gap) 0 0;
        border: 1px solid var(--line);
        border-radius: 14px;
        box-shadow: 0 10px 30px rgba(28, 25, 23, 0.10), 0 2px 6px rgba(28, 25, 23, 0.05);
    }
    [data-theme="dark"] .app-shell-rail .topbar-app {
        box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45), 0 2px 6px rgba(0, 0, 0, 0.30);
    }

    /* AND THE FOOTER IS THE THIRD PANEL — same argument as the toolbar above.
       Once the rail and the toolbar float, a full-bleed band welded to the bottom
       of the window is the last thing still belonging to the old layout: the eye
       reads two objects resting on the ground and then a strip nailed to the
       frame. Same inset, radius, hairline and elevation, so the shell is a column
       of panels between two equal gutters.
       ⚠ `border`, not `border-top`: the base rule's `border-top: 1px var(--line-soft)`
       is the divider a full-bleed bar needs and is exactly wrong on a panel, which
       needs an outline on all four sides. Higher specificity (0,2,0) overrides it
       wherever the rule lands in the file.
       ⚠ Scoped to .app-shell-rail like the toolbar, so three other footers keep
       their own treatment: the control plane (no floating rail), the portal's
       `.brand-footer-powered` (deliberately unbranded), and the sign-in shell's
       `.auth-shell > .brand-footer`, which is full-bleed ON PURPOSE. */
    .app-shell-rail .brand-footer {
        margin: 0 var(--rail-gap) var(--rail-gap) 0;
        border: 1px solid var(--line);
        border-radius: 14px;
        box-shadow: 0 10px 30px rgba(28, 25, 23, 0.10), 0 2px 6px rgba(28, 25, 23, 0.05);
    }
    [data-theme="dark"] .app-shell-rail .brand-footer {
        box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45), 0 2px 6px rgba(0, 0, 0, 0.30);
    }

    /* The content column keeps the same right gutter so the page reads as a
       column of panels rather than a panel and then a full-bleed sheet. */
    .app-shell-rail .app-main > .content-scroll,
    .app-shell-rail .app-main > .main-content { padding-right: var(--rail-gap); }

    /* ⚠⚠ KEYBOARD FOCUS OPENS THE RAIL AT ONCE — no --rail-in. The open delay
       exists to ignore a pointer that is merely crossing the panel; a Tab into the
       rail is a decision, and there is nothing to ignore.
       This is not a nicety, it is the a11y half of putting the labels on the panel's
       clock. The submenu is `display: none` until the panel is WIDE, so any delay
       here is a window in which the open group's links are not yet in the tab order
       — tab quickly and focus walks straight past them to the next group. At 0s the
       panel is already open by the time the next Tab is pressed.
       ⚠ One value covers the whole list: `transition-delay` repeats when it has fewer
       values than `transition-property`, so this is three zeros — width, overflow-x,
       overflow-y — without naming any of them.
       ⚠ The rail is now the ONLY thing here that needs it. The rows and the key used
       to be listed too, back when the centring and the pin's slide were timed flips;
       both are geometry now (the container queries above), so they have no delay left
       to zero out.
       ⚠ Must stay BELOW the `:hover, :focus-within, .pinned` rule that sets --rail-in
       — the two tie on specificity, so order is what decides. */
    .navrail:focus-within { transition-delay: 0s; }

    /* Someone who has asked the OS for less motion gets the rail's states, not
       its animation — it still opens and shuts, instantly. */
    @media (prefers-reduced-motion: reduce) {
        .navrail,
        .app-shell-rail,
        /* ⚠ The pin is deliberately NOT in this list any more, and neither is its
           glyph: the key no longer slides (it lives at a fixed `right: 6px`) and no
           longer rotates (the thumbtack is stationary), so it has no transition left
           to turn off. Earlier revisions listed both and had to. */
        /* ⚠⚠ AND THE TEXT'S FOLD-OUT, WHICH IS DELAYED RATHER THAN ANIMATED. This is
           the one entry here that is not about smoothing something out: on the way out
           the labels WAIT --rail-out before going, so leaving this off would hand
           someone who asked for less motion a rail that snaps shut instantly and then
           holds its text in mid-air for a tenth of a second.
           ⚠ Only the way out needs it. The way IN is the container queries above, and
           a container query is geometry — with the width transition already off, the
           panel is at its final size on the same frame and the text folds with it.
           ⚠ This beats the delay above on document order rather than specificity: the
           rule up there sets `transition-delay` alone and this sets the whole
           shorthand, so `transition-property: none` lands and nothing is left with a
           delay to serve. Keep that one longhand-only and this keeps working. */
        .navrail:not(:hover):not(:focus-within):not(.pinned) :is(.nav-lb, .navrail-brand-name, .nav-ct, .navrail-children, .navrail-glabel-t) { transition: none; }
    }
}

@media (max-width: 1024px) {
    .navrail {
        position: fixed;
        left: 0;
        top: var(--topbar-h);
        bottom: 0;
        z-index: 560;
        transform: translateX(-100%);
        transition: transform 0.18s ease;
        box-shadow: var(--shadow-md);
    }

    .navrail.open { transform: translateX(0); }

    .navrail-backdrop { display: block; }

    /* ⚠⚠ NO COLLAPSE KEY BELOW 1025px, because there is nothing here for it to do.
       Every rule that gives the pin meaning — its position, its size, the panel it
       pins open — lives in the ≥1025px block, because the collapse/expand rail only
       exists there. Down here the rail is an OFF-CANVAS OVERLAY driven by the burger:
       it has one state, it is either slid in or slid out, and "keep it open" is not a
       thing you can ask of it.
       Left unstyled the button still RENDERED — a bare 22px <button> with a thumbtack
       in it, sitting in the overlay's flex column between the brand and Home, pinning
       nothing. It was invisible in the desktop reviews because the desktop block
       absolutely-positions it out of the flow. */
    .navrail-pin { display: none; }
}

/* --- Status Strip ------------------------------------------------------ */
/* Blueprint: no grey band across the top — the strip's crumb/buttons sit
   straight on the drafting-grid canvas, reading as the page's first row. */
/* ⚠ The bottom padding is load-bearing, and it is 12 rather than 0 for a
   reason. The only thing separating this strip from the page below it used to
   be .content-scroll's TOP padding — which is inside the scrolling box, so it
   scrolls away. Measured: 0px of clearance the moment you scroll, which put
   the first panel's action row (e.g. "+ Assign Crew") flush against this
   strip's Edit button, reading as an overlap.
   The gap has to live OUTSIDE the scroll container to survive scrolling, so it
   sits here; the paired rule below takes the same 12px back off
   .content-scroll's top so the RESTING layout is unchanged (24px, measured
   before and after). See Design/statusstrip-probe.html. */
.status-strip {
    flex-shrink: 0;
    background: transparent;
    padding: 24px 28px 12px;   /* aligns with the mockup .content gutters */
    display: flex;
    flex-direction: column;
    gap: 6px;
}

/* Scoped to a page that actually has a status strip — .content-scroll is used
   by every page in the app and most have no strip above them. */
.status-strip + .main-content > .content-scroll { padding-top: 12px; }

.strip-breadcrumb {
    font-size: var(--fs-xs);
    color: var(--navy-soft);
    display: flex;
    align-items: center;
    gap: 6px;
}

.strip-breadcrumb .sep { color: var(--glyph-ui); }

.strip-stage-row {
    display: flex;
    gap: 4px;
    align-items: center;
}

.strip-stage {
    font-size: var(--fs-2xs);
    font-weight: 600;
    padding: 3px 10px;
    border-radius: var(--r-pill);
    color: var(--navy-soft);
    background: transparent;
    border: 1px solid var(--line);
}

.strip-stage.current {
    background: var(--sky-light);
    color: var(--navy);
    border-color: var(--sky);
    /*box-shadow: 0 0 0 3px var(--sky-soft);*/
}

/* --- Action Bar ------------------------------------------------------- */
/* Blueprint: the page header is not a bar — the h1 sits directly on the
   canvas (mockup .h1, 21px/700) with the actions on its right. The old warm
   band is gone; nested action bars (inside strips/boxes) stay compact. */
.action-bar {
    min-height: var(--actionbar-h);
    flex-shrink: 0;
    display: flex;
    align-items: center;
    padding: 0 14px;
    gap: 8px;
    background: transparent;
    margin-left: auto;
}

/* Bars nested inside a status strip already sit in padded context. */
.strip-breadcrumb .action-bar { padding: 0; min-height: 0; }

.action-bar-title {
    font-size: 21px;       /* mockup .h1 — page titles carry the hierarchy */
    font-weight: 700;
    color: var(--navy);
    letter-spacing: -0.01em;
}

.action-bar-spacer { flex: 1; }

/* Action bar placed inside a page column (not the shell edge) — cancel the
   auto left margin (replaces the repeated inline style="margin: unset;"). */
.action-bar-flush { margin: 0; }

/* --- Main Content ----------------------------------------------------- */
.main-content {
    flex: 1;
    overflow: hidden;
    display: flex;
    flex-direction: column;
}

.content-scroll {
    flex: 1;
    overflow-y: auto;
    padding: 24px 28px 30px;   /* mockup .content */
    /* Fill the viewport: the old margin-right:auto disabled flex stretching,
       collapsing every detail page to its content's natural width with a
       dead zone on the right. Gutters live in the padding now. */
    width: 100%;
    overflow-x: hidden;

    /* Hide scrollbar in Firefox */
    scrollbar-width: none;

    /* Hide scrollbar in IE 10+ and Edge (legacy) */
    -ms-overflow-style: none;
}
/* Hide scrollbar in Chrome, Safari, and newer Edge */
.content-scroll::-webkit-scrollbar {
    display: none;
}

/* --- Buttons ---------------------------------------------------------- */
/* Mockup .btn2 — the default button: white, hairline border, 600/12.5px.

   ⚠ THE HEIGHT IS PINNED, NOT DERIVED. It used to be `min-height: 32px` over
   7.5px of vertical padding, which does not produce 32px: 12.5px text on the
   body's 1.5 line-height is an 18.75px line box, so the real button measured
   35.75px — and .btn-primary, one font step up on 8.5px padding, measured
   38.5px. Two buttons sitting side by side in the same action bar were ~3px
   different heights, and pages had begun papering over it one at a time with
   inline `height: 32px` (and, in two places, 33px). So the box is stated here
   and the vertical padding is zero: align-items:center already centres the line
   box inside it.
   ⚠ Anything DELIBERATELY outside the 30–40px band must opt out by hand, because
   `height` here reaches every .btn: .btn-sm (28px) and .view-toggle .btn (26px)
   below both re-declare `height: auto`. The 44px phone/coarse floors need no
   such thing — min-height always beats height, so those still win. */
.btn {
    font-family: var(--font-sans);
    font-size: 12.5px;
    font-weight: 600;
    padding: 0 12px;
    height: 32px;
    min-height: 32px;
    border-radius: var(--r-sm);
    border: 1px solid var(--line);
    background: var(--card);
    color: var(--navy);
    cursor: pointer;
    white-space: nowrap;
    transition: background 0.12s, border-color 0.12s, box-shadow 0.12s, transform 0.08s;
    display: inline-flex;
    align-items: center;
    gap: 7px;
    text-decoration: none;
}

.btn:hover {
    background: var(--fill);
    border-color: var(--line-mid);
}

.btn:active { transform: translateY(1px); }

/* height:auto opts out of .btn's pinned 32px box — this one is 28px on purpose. */
.btn-sm { padding: 4px 10px; font-size: var(--fs-xs); height: auto; min-height: 24px; }

/* Primary action wears the Blueprint redline — the one saturated element on
   any given screen, which is exactly what makes it findable. */
/* Mockup .btn — the redline primary. It still reads a touch larger than the plain
   button, through its type and its side padding; it no longer does so by being
   TALLER, which only ever showed up as a ragged action bar. */
.btn-primary {
    background: var(--accent);
    color: #fff;
    border-color: var(--accent);
    font-size: var(--fs-sm);
    padding: 0 14px;
}

.btn-primary:hover {
    background: var(--accent-dn);
    border-color: var(--accent-dn);
}

.btn-sky {
    background: var(--sky-light);
    color: var(--navy);
    border-color: transparent;
}

.btn-sky:hover {
    background: var(--deep);
    color: #fff;
}

/* View toggle (Kanban / Table) */
.view-toggle {
    display: flex;
    gap: 2px;
    background: var(--fill);
    padding: 3px;
    border-radius: var(--r-pill);
}

.view-toggle .btn {
    border: none;
    background: transparent;
    padding: 3px 10px;
    height: auto;    /* opts out of .btn's pinned 32px box */
    min-height: 0;   /* stays a compact segmented control */
    font-size: var(--fs-xs);
}

.view-toggle .btn.active {
    background: var(--card);
    box-shadow: var(--shadow-soft);
    color: var(--navy);
    font-weight: 600;
}

/* --- Pills / Badges --------------------------------------------------- */
/* Mockup .pill: 11.5px/600, 4px 9px, transparent border so the tinted status
   variants can draw a same-hue edge without shifting the layout. */
.pill {
    display: inline-flex;
    align-items: center;
    gap: 5px;
    font-size: 11.5px;
    font-weight: 600;
    padding: 4px 9px;
    border: 1px solid transparent;
    border-radius: var(--r-pill);
    white-space: nowrap;
}

/* Blueprint pill dot: same hue as the pill's ink (currentColor), so every
   status pill leads with its color swatch like the mockup's. */
.pill-dot {
    width: 7px;        /* mockup .sdot */
    height: 7px;
    border-radius: 50%;
    background: currentColor;
    flex: none;
}

.pill-sky   { background: var(--sky-light); color: var(--navy); }
.pill-navy  { background: var(--navy); color: #fff; }
.pill-ok    { background: var(--ok-light); color: var(--ok-ink); }
.pill-warn  { background-color: var(--card); background-image: linear-gradient(var(--warn-light), var(--warn-light)); color: var(--warn-ink); }
.pill-bad   { background: var(--bad-light); color: var(--bad-ink); }
/* ⚠ .pill-info shares .pill-fill's treatment rather than inventing a hue. It is
   the NEUTRAL pill — a fact rather than a status ("+10.5%", "cannot coach yet") —
   and scripts 225/226 shipped four of them against a class that did not exist.
   Two names, one declaration, so they cannot drift into looking like two things
   that mean two things. */
.pill-fill,
.pill-info  { background: var(--fill); color: var(--navy-soft); }
.pill-deep  { background: var(--deep); color: #fff; }
/* The ABSENCE pill — "nothing has been recorded here", which is not the same as
   a value of zero and must not look like one. Scripts 225/226 shipped six of
   these (not costed yet · no expiry recorded · cancelled) against a class that
   did not exist, so they rendered as bare .pill in the default ink — the exact
   sameness they were added to break. Quieter than .pill-fill on purpose: it is
   the only pill that reports a gap rather than a state. */
.pill-muted { background: var(--fill-soft); color: var(--text-muted); }
/* Anything the machine wrote wears the violet — the palette's fifth colour, a
   meaning and not a status, which is why it sits apart from the hue pills. */
.pill-ai    { background: var(--ai-tint); color: var(--ai); }

/* Mockup status pills carry a same-hue edge (.p-green border rgba(g-ink,.2)).
   Derived from the pill's own ink, so every tinted variant — including the
   stage pills below — gets it in both themes. Solid pills keep their fill. */
.pill-ok, .pill-warn, .pill-bad, .pill-fill, .pill-ai, .pill-muted, .pill-info,
[class*="stage-"].pill { border-color: color-mix(in srgb, currentColor 22%, transparent); }
/* .pill-sky's ink is the near-black text, not its hue — derive its edge from
   the redline itself or it draws a grey-black hairline on a terracotta tint. */
.pill-sky { border-color: color-mix(in srgb, var(--sky) 22%, transparent); }

/* Stage-specific pills */
.stage-lead       { background: var(--fill); color: var(--navy-soft); }
.stage-estimate   { background: var(--warn-light); color: var(--stage-estimate-fg); }
.stage-approved   { background: var(--ok-light); color: var(--ok-ink); }
.stage-materials  { background: var(--stage-materials-bg); color: var(--stage-materials-fg); }
.stage-scheduled  { background: var(--stage-inprogress-bg); color: var(--stage-inprogress-fg); }   /* blue family — the terracotta tint reads as the error wash */
.stage-inprogress { background: var(--stage-inprogress-bg); color: var(--stage-inprogress-fg); }
.stage-walkthrough{ background: var(--stage-walkthrough-bg); color: var(--stage-walkthrough-fg); }
.stage-paid       { background: var(--ok-light); color: var(--ok-ink); }
.stage-archived   { background: var(--fill); color: var(--navy-soft); }

/* --- Cards / Box ------------------------------------------------------- */
/* Mockup .card: hairline + 12px radius and NOTHING else — Blueprint takes its
   depth from the lines, so cards cast no shadow (the shadow tokens are for
   true overlays: modals, dropdowns, popovers). */
.box {
    border: 1px solid var(--line);
    border-radius: var(--r);
    background: var(--card);
    padding: 14px 16px;
}

.box-soft {
    border: 1px solid var(--line-soft);
    border-radius: var(--r-sm);
    background: var(--card);
    padding: 10px;
}

/* --- Typography ------------------------------------------------------- */
.lbl {
    font-size: var(--fs-2xs);
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.08em;
    color: var(--navy-soft);
}

.mono {
    font-family: var(--font-mono);
    font-size: var(--fs-xs);
    font-variant-numeric: tabular-nums;
}

.tiny {
    font-size: var(--fs-2xs);
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.04em;
}

h1, h2, h3, h4 { font-family: var(--font-sans); }
/* No anchor may ever fall back to the UA's #0000EE — it is unreadable in the
   dark theme and off-palette in the light one. Scoped link rules below still
   win on specificity. */
a, a:visited { color: var(--accent); }

/* --- A button that is an <a> must survive :visited --------------------- */
/* ⚠⚠ THIS IS WHY THE "Company settings" BUTTON RENDERED BLANK. The rule
   directly above is specificity (0,1,1). A single class — `.btn-primary` — is
   (0,1,0), so it LOSES, and the moment the user has actually visited the href
   the anchor's label is repainted in var(--accent): accent-blue text on the
   accent-blue background it sets itself. Contrast 1:1. Invisible.
   ⚠ Unvisited it looks perfect, so it survives every review, every
   screenshot taken on a fresh profile, and every headless test — the bug
   arrives only after somebody uses the button once.
   ⚠⚠ getComputedStyle CANNOT SEE THIS. Browsers deliberately report the
   UNVISITED style for :visited to stop history sniffing, so the DOM claims
   `rgb(255,255,255)` while the screen paints blue-on-blue. Measured both ways
   here: the computed value is white before and after visiting, and only a
   specificity argument (or a screenshot in a real profile) reveals it. Do not
   "verify" a link colour with getComputedStyle.
   ⚠ The dark theme was never affected: `[data-theme="dark"] .btn-primary` is
   (0,2,0) and already outranks a:visited. This is a light-theme-only fault.
   ⚠ Placement is load-bearing: `.btn:visited` ties a:visited at (0,1,1), so it
   has to sit AFTER it. `.btn-primary:visited` is (0,2,0) and would win either
   way — keep them together anyway.
   ⚠ `.card-link, .card-link:visited` further down already does exactly this.
   The idiom existed; the buttons were simply missed. ANY new colour rule that
   can land on an <a> needs its :visited twin. */
.btn,         .btn:visited         { color: var(--navy); }
.btn-primary, .btn-primary:visited { color: #fff; }
.btn-sky,     .btn-sky:visited     { color: var(--navy); }
.btn-sky:hover, .btn-sky:visited:hover { color: #fff; }
[data-theme="dark"] .btn-primary,
[data-theme="dark"] .btn-primary:visited { color: #1C1917; }
h2 { font-size: var(--fs-h2); font-weight: 600; letter-spacing: -0.01em; }
h3 { font-size: var(--fs-md); font-weight: 600; letter-spacing: -0.01em; }
h4 { font-size: var(--fs-sm); font-weight: 700; }

/* --- Layout Helpers ---------------------------------------------------- */
.row   { display: flex; align-items: center; }
.col   { display: flex; flex-direction: column; }
.gap4  { gap: 4px; }
.gap6  { gap: 6px; }
.gap8  { gap: 8px; }
.gap12 { gap: 12px; }
.gap14 { gap: 14px; }
.flex1 { flex: 1; }
.ml-auto { margin-left: auto; }

/* --- KPI Strip --------------------------------------------------------- */
/* Blueprint narrated-tile anatomy (mockup .tile): micro-cap label ABOVE the
   value — flipped here in CSS (order:-1) so all ~40 existing kpi strips
   inherit the mockup's anatomy without markup edits. */
.kpi-strip {
    display: flex;
    gap: 10px;
    margin-bottom: 14px;
}

.kpi-box {
    flex: 1;
    display: flex;
    flex-direction: column;
    border: 1px solid var(--line);
    border-radius: var(--r);
    background: var(--card);
    padding: 14px 16px 13px;   /* mockup .tile */
}

.kpi-box .kpi-val {
    font-size: 25px;           /* mockup .tile .v */
    font-weight: 650;
    color: var(--navy);
    line-height: 1.2;
    letter-spacing: -0.01em;
    font-variant-numeric: tabular-nums;
}

.kpi-box .kpi-label {
    order: -1;             /* label above value, mockup tile anatomy */
    font-family: var(--font-mono);
    font-size: var(--fs-2xs);   /* was 10.5px — see the micro-label floor note in :root */
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.16em;
    color: var(--ink3);    /* mockup .tile .microcap */
    margin-bottom: 9px;
}

/* --- Progress Bar ------------------------------------------------------- */
.progress-wrap {
    background: var(--fill);
    border-radius: var(--r-pill);
    overflow: hidden;
}

.progress-fill {
    border-radius: var(--r-pill);
    background: var(--navy);   /* completion is neutral ink, not the redline */
    transition: width 0.3s ease;
}

.progress-wrap.thin .progress-fill { height: 5px; }
.progress-wrap.std  .progress-fill { height: 8px; }
.progress-wrap.md   .progress-fill { height: 10px; }

.progress-fill.ok   { background: var(--ok); }
.progress-fill.warn { background: var(--warn); }
.progress-fill.bad  { background: var(--bad); }

/* --- Tables ------------------------------------------------------------- */
.tbl-wrap {
    border: 1px solid var(--line);
    border-radius: var(--r);
    overflow: hidden;
    overflow-x: auto; /* wide tables scroll horizontally instead of clipping */
    background: var(--card);
}

/* Mockup table.grid: 13.5px rows, mono micro-cap headers on the card itself
   (no grey band), hairline rules under the head and between rows. */
table.tbl {
    width: 100%;
    border-collapse: collapse;
    font-size: 13.5px;
}

table.tbl thead th {
    background: transparent;
    padding: 11px 16px;
    font-family: var(--font-mono);
    font-size: var(--fs-2xs);   /* was 10.5px — see the micro-label floor note in :root */
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.12em;   /* Blueprint's letterspaced micro-cap header voice */
    color: var(--ink3);
    border-bottom: 1px solid var(--line);
    text-align: left;
    white-space: nowrap;
}

table.tbl thead th.th-sort {
    cursor: pointer;
    user-select: none;
}

table.tbl thead th.th-sort:hover {
    color: var(--sky);
}

.sort-caret {
    margin-left: 3px;
    font-size: 14px;
    opacity: 0.3;
}

.sort-caret.active {
    opacity: 1;
    color: var(--sky);
}

/* Record numbers in a row read at the row's size (our .mono also shrinks text;
   the mockup's only swaps the family). Covers both spellings — most pages wrap
   the number in a <span class="mono"> rather than tagging the cell. */
table.tbl tbody td.mono,
table.tbl tbody td .mono { font-size: inherit; }

table.tbl tbody td {
    padding: 11.5px 16px;   /* mockup table.grid td */
    border-bottom: 1px solid var(--line-soft);
    vertical-align: middle;
    color: var(--navy);
    font-variant-numeric: tabular-nums;   /* digits align down every column */
}

table.tbl tbody tr:last-child td {
    border-bottom: none;
}

table.tbl tbody tr:hover td {
    background: var(--sky-soft);
}

/* Row background variants */
table.tbl tbody tr.row-overdue  td { background: var(--row-overdue) !important; }
table.tbl tbody tr.row-display  td { background: var(--row-display) !important; }
table.tbl tbody tr.row-reorder  td { background: var(--row-reorder) !important; }
table.tbl tbody tr.row-both     td { background: var(--row-both) !important; }
table.tbl tbody tr.row-employee td { background: var(--row-employee) !important; }

/* --- Kanban Board ------------------------------------------------------- */
/* The board takes the room LEFT OVER in its scroll pane, not the pane's full
   height: the page title and filter chips share that pane now, and height:100%
   pushed the columns' last cards below the (scrollbar-less) fold. */
.kanban-board {
    display: flex;
    gap: 8px;
    overflow-x: auto;
    flex: 1;
    min-height: 0;
    padding-bottom: 4px;
}

.kanban-col {
    width: 200px;
    flex-shrink: 0;
    display: flex;
    flex-direction: column;
    gap: 6px;
}

.kanban-col-header {
    display: flex;
    align-items: center;
    gap: 6px;
    padding: 5px 8px;
    border-radius: var(--r-sm);
    font-size: var(--fs-xs);
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.04em;
}

.kanban-col-header .col-count {
    margin-left: auto;
    font-size: var(--fs-2xs);
    font-weight: 700;
    opacity: 0.8;
}

.kanban-cards {
    display: flex;
    flex-direction: column;
    gap: 5px;
    overflow-y: auto;
    flex: 1;
}

.kanban-card {
    background: var(--card);
    border: 1px solid var(--line);
    border-radius: var(--r-sm);
    padding: 8px;
    box-shadow: var(--shadow-soft);
    cursor: pointer;
    transition: box-shadow 0.12s;
}

.kanban-card:hover {
    box-shadow: var(--shadow-md);
}

.kanban-card-name {
    font-size: var(--fs-sm);
    font-weight: 600;
    color: var(--navy);
    margin-bottom: 2px;
}

.kanban-card-sub {
    font-size: var(--fs-2xs);
    color: var(--navy-soft);
    margin-bottom: 4px;
}

.kanban-card-tags {
    display: flex;
    flex-wrap: wrap;
    gap: 3px;
    margin-top: 4px;
}

/* Kanban column color variants */
.kanban-col-lead       .kanban-col-header { background: var(--fill); color: var(--navy-soft); }
.kanban-col-estimate   .kanban-col-header { background: var(--warn-light); color: var(--stage-estimate-fg); }
.kanban-col-approved   .kanban-col-header { background: var(--ok-light); color: var(--ok-ink); }
.kanban-col-inprogress .kanban-col-header { background: var(--stage-inprogress-bg); color: var(--stage-inprogress-fg); }
.kanban-col-walkthrough .kanban-col-header { background: var(--stage-walkthrough-bg); color: var(--stage-walkthrough-fg); }
.kanban-col-paid       .kanban-col-header { background: var(--ok-light); color: var(--ok-ink); }

/* --- Accordion -------------------------------------------------------- */
.accordion {
    border: 1px solid var(--line);
    border-radius: var(--r);
    overflow: hidden;
    background: var(--card);
}

.accordion-header {
    display: flex;
    align-items: center;
    padding: 8px 12px;
    cursor: pointer;
    background: var(--fill);
    font-size: var(--fs-base);
    font-weight: 600;
    color: var(--navy);
    border-bottom: 1px solid var(--line);
    transition: background 0.12s;
    gap: 8px;
    user-select: none;
}

.accordion-header:hover { background: var(--sky-light); }
.accordion-header.open  { background: var(--navy); color: #fff; }

.accordion-toggle {
    margin-left: auto;
    font-size:9px;   /* caret glyph */
    opacity: 0.7;
}

.accordion-body {
    padding: 12px;
}

/* --- Search Bar ------------------------------------------------------- */
.search-input-wrap {
    position: relative;
    max-width: 360px;
}

.search-input-wrap .search-icon {
    position: absolute;
    left: 10px;
    top: 50%;
    transform: translateY(-50%);
    color: var(--glyph-ui);
    font-size:12px;   /* icon glyph */
    pointer-events: none;
}

.search-input {
    width: 100%;
    font-family: var(--font-sans);
    font-size: var(--fs-sm);
    padding: 5px 10px 5px 28px;
    border: 1px solid var(--line);
    border-radius: var(--r-pill);
    background: var(--card);
    color: var(--navy);
    outline: none;
    transition: border-color 0.12s;
}

.search-input:focus {
    border-color: var(--sky);
    box-shadow: 0 0 0 3px var(--sky-soft);
}

/* --- Legend Row ------------------------------------------------------- */
.legend-row {
    display: flex;
    gap: 12px;
    align-items: center;
    padding: 6px 8px;
    background: var(--fill-soft);
    border-top: 1px solid var(--line);
    border-radius: 0 0 var(--r) var(--r);
}

.legend-item {
    display: flex;
    align-items: center;
    gap: 5px;
    font-size: var(--fs-2xs);
    color: var(--navy-soft);
}

.legend-swatch {
    width: 12px;
    height: 12px;
    border-radius: 3px;
    border: 1px solid var(--line);
}

/* --- Avatar ----------------------------------------------------------- */
.avatar {
    width: 24px;
    height: 24px;
    border-radius: 50%;
    background: var(--deep);
    color: #fff;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    font-size:9px;   /* avatar initials — glyph sizing */
    font-weight: 700;
    flex-shrink: 0;
}

.avatar-lg {
    width: 48px;
    height: 48px;
    font-size: 14px;
}

/* --- Stepper ---------------------------------------------------------- */
.stepper {
    display: flex;
    align-items: center;
    gap: 0;
}

.stepper-step {
    display: flex;
    align-items: center;
    flex-direction: column;
    position: relative;
}

.stepper-circle {
    width: 18px;
    height: 18px;
    border-radius: 50%;
    border: 1.5px solid var(--line-mid);
    background: var(--card);
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: var(--fs-2xs);   /* was 8px — floored to the smallest scale step */
    font-weight: 700;
    color: var(--navy-soft);
    z-index: 1;
    position: relative;
}

.stepper-circle.done {
    background: var(--ok);
    border-color: var(--ok);
    color: #fff;
}

.stepper-circle.current {
    background: var(--sky);
    border-color: var(--sky);
    color: #fff;
    box-shadow: 0 0 0 3px var(--sky-soft);
}

.stepper-label {
    font-size: var(--fs-2xs);   /* was 8px — floored to the smallest scale step */
    color: var(--navy-soft);
    margin-top: 3px;
    white-space: nowrap;
    text-align: center;
}

.stepper-line {
    height: 2px;
    width: 28px;
    background: var(--line);
    margin: 0 -1px;
    margin-bottom: 14px;
    flex-shrink: 0;
}

.stepper-line.done { background: var(--ok); }
.stepper-line.current { background: var(--sky); }

/* --- Sentiment Bar ---------------------------------------------------- */
.sentiment-bar-wrap {
    display: flex;
    gap: 2px;
    height: 32px;
    align-items: flex-end;
}

.sentiment-bar {
    flex: 1;
    border-radius: 2px 2px 0 0;
}

.sentiment-bar.positive { background: var(--ok); }
.sentiment-bar.neutral  { background: var(--warn); }
.sentiment-bar.negative { background: var(--bad); }

/* --- Scrollbars ------------------------------------------------------- */
/* Standards-track twin of the ::-webkit-scrollbar block below, so Firefox gets
   the same thin scrollbar. Scoped (not `*`) so it cannot override
   .content-scroll's deliberate `scrollbar-width: none`. */
.tbl-wrap, .modal-overlay, .modal-body { scrollbar-width: thin; scrollbar-color: var(--line) transparent; }
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--line); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--line-mid); }

/* --- Utility ---------------------------------------------------------- */
.text-sky    { color: var(--sky); }
.text-navy   { color: var(--navy); }
.text-soft   { color: var(--navy-soft); }
.text-muted  { color: var(--text-muted); }
.text-ok     { color: var(--ok-ink); }
.text-warn   { color: var(--warn-ink); }
.text-bad    { color: var(--bad-ink); }

/* --- BLUEPRINT job hub (Sheet 02) ---------------------------------------- */
.bp-projhead { display: flex; align-items: flex-start; gap: 16px; padding: 4px 2px 12px; flex-wrap: wrap; }
/* The title block needs a flex BASIS, not just min-width. Flex line-breaking
   runs off the basis, and with `auto` that is the block's max-content — the
   promise line, 600px+ — so the line broke and the action buttons dropped to a
   second row at every laptop width, even when a small shrink would have kept
   them beside the title. Measured (Design/projhead-probe.html): adding only
   `min-width:0; flex:1 1 auto` changes NOTHING — the wrap decision is identical
   in all 16 cases. A basis of 0 is far worse: it collapses the block to 67px
   and stands the promise line up as a 215px ribbon. 22rem is a floor, so while
   the buttons share the row the title can never be squeezed below it; once they
   genuinely don't fit, the row still wraps and the block takes the full width. */
.bp-projhead-l { min-width: 0; flex: 1 1 22rem; }
.bp-projhead-title { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.bp-projhead-title h1 { font-size: 21px; font-weight: 700; letter-spacing: -0.01em; color: var(--navy); margin: 0; line-height: 1.15; }   /* mockup .h1 */
.bp-recno {
    font-size: 11px; font-weight: 600; letter-spacing: 0.06em;
    color: var(--text-muted); border: 1px solid var(--line); border-radius: 6px;
    padding: 4px 7px; background: var(--card); white-space: nowrap;
}
.bp-projhead-sub { margin-top: 3px; font-size: var(--fs-sm); color: var(--text-muted); }   /* mockup .h1sub */
.bp-projhead-sub a { color: var(--sky); text-decoration: none; font-weight: 600; }
.bp-projhead-actions { margin-left: auto; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; min-width: 0; }

/* --- A max-length value must not break the layout, at ANY width -------- */
/* The overflow-hardening block near the end of this file already does this
   job — but only inside @media (max-width: 760px), because it was written
   for the phone case, where body{overflow-x:hidden} CLIPS whatever spills.
   An unbreakable token overflows its OWN CONTAINER at every width.
   ⚠ The value is not abuse and must not be "fixed" by tightening input:
   dbo.Customers.Name is NVARCHAR(200) and all three layers — the input's
   maxlength, TextGuards.RequireMaxLength, the column — accept all 200 on
   purpose. A 200-character name is VALID DATA the app has to render.
   Measured on /customers/33 at 1440px BEFORE this block: the <h1> ran
   +1920px past its parent, which is why the action buttons sat on top of
   the name, and the email/address spans ran +2875px and +2235px, out of
   their card and onto the neighbouring column.
   Re-measure with tools/e2e/overflow-probe.mjs — it exits non-zero on spill.
   ⚠ `break-word`, NEVER `anywhere`: `anywhere` also lowers the min-content
   contribution, which lets a wide table be squeezed until money and document
   numbers break mid-value. Same reasoning the phone block records. */
.bp-projhead-title { min-width: 0; }
.bp-projhead-title h1,
.bp-projhead-sub { min-width: 0; overflow-wrap: break-word; }

/* A card must be allowed to be narrower than the widest thing inside it, and
   what is inside must wrap rather than escape onto the next column.
   ⚠ min-width:0 is load-bearing and break-word alone does NOT replace it:
   break-word deliberately keeps the intrinsic min-content width, so a grid
   or flex item still refuses to shrink below the whole 300-character string. */
.box { min-width: 0; overflow-wrap: break-word; }

/* ⚠ The card's own min-width:0 is NOT enough, and this is the subtle half.
   A label/value row is display:flex, so the VALUE is a flex item, and a flex
   item defaults to min-width:auto — it refuses to shrink below its content's
   min-content width, which break-word deliberately leaves at the full string.
   So a 300-character address stayed 2875px wide and walked out of the card
   onto the next column even with everything above applied. Measured: this was
   the last remaining spill on /customers/33.
   ⚠ Scoped to `span:not(.lbl)` deliberately — `.row > *` would also let the
   72px labels and any buttons in a `.row` be squeezed, and .lbl carries its
   width inline where min-width:0 would win. */
.row > span:not(.lbl) { min-width: 0; }

/* Promoted out of the phone block together with the rule above, and for the
   same reason it exists there: .box now passes break-word down to every
   descendant, and an amount, account code or record number must never be
   broken mid-value. */
.num, .mono, .kpi-val { overflow-wrap: normal; }

/* One pathological value must not stretch a whole table. .tbl-wrap scrolls by
   design, but a single 200-character name took the customers table to 4306px:
   every other row became unreadable and the useful columns left the screen.
   ⚠ Applied to an inner block, NOT the <td> — with table-layout:auto a
   max-width on the cell itself is a hint the browser is free to ignore.
   The full value stays reachable: every use carries a title attribute. */
/* ⚠⚠ min(40ch, 100%), NOT 40ch. The 40ch cap stops one pathological value
   stretching a wide table (above). It does NOT stop the value overflowing a NARROW
   container, and on a phone that is the whole problem: .tbl-cards collapses each row
   into a two-column grid whose cells are display:flex with align-items:flex-start, so
   a white-space:nowrap child is sized by its own text and 40ch (~320px) sits in a
   ~160px card column. Measured on /employees at 375px: one 70-character Schedule value
   with no space in it took the page 549px sideways, and 78px even after the clip was
   applied — min(40ch, 100%) takes it to 0 while keeping the desktop cap intact.
   Re-measure with tools/e2e, the uxinstrument walk’s `sideways scroll` line. */
.cell-clip { max-width: min(40ch, 100%); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* --- CRM activity timeline (ActivityTimeline.razor) ------------------- */
/* The compose row sits ABOVE the list: logging a call is the frequent action,
   reading history the occasional one. Entries are a plain vertical run with a
   hairline rule between them rather than a dotted "timeline" spine — the
   Blueprint language draws with lines, not decoration. */
.act-compose {
    display: flex; flex-direction: column; gap: 8px;
    margin: 10px 0 4px; padding-bottom: 12px;
    border-bottom: 1px solid var(--line);
}
.act-kinds, .act-outcomes { display: flex; flex-wrap: wrap; gap: 6px; }
.act-body {
    width: 100%; resize: vertical; min-height: 44px;
    font: inherit; font-size: var(--fs-sm); line-height: 1.5;
    padding: 8px 10px; border: 1px solid var(--line); border-radius: var(--r-sm);
    background: var(--paper); color: var(--navy);
}
.act-body:focus { outline: none; border-color: var(--sky); }
.act-compose-foot { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.act-compose-spacer { flex: 1; }
.act-when { display: inline-flex; align-items: center; gap: 6px; }
.act-when input { font-size: var(--fs-xs); padding: 5px 8px; }

.act-list { display: flex; flex-direction: column; margin-top: 4px; }
.act-item {
    display: flex; gap: 10px; align-items: flex-start;
    padding: 10px 0; border-bottom: 1px solid var(--line);
}
.act-item:last-child { border-bottom: 0; }
.act-icon { font-size: 15px; line-height: 1.4; flex: none; }
.act-main { flex: 1 1 auto; min-width: 0; }
.act-head { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.act-kind { font-size: var(--fs-xs); font-weight: 600; color: var(--navy); }
.act-meta { font-size: var(--fs-2xs); color: var(--text-muted); }
/* Long unbroken values (emails, URLs) must wrap, not push the card wider —
   the phone-zoom trap this codebase has already paid for once. */
.act-body-text {
    margin-top: 3px; font-size: var(--fs-sm); color: var(--navy);
    white-space: pre-wrap; overflow-wrap: anywhere;
}
.act-del {
    border: 0; background: none; cursor: pointer; padding: 2px 4px;
    font-size: 11px; color: var(--text-muted); border-radius: 4px;
}
.act-del:hover { color: var(--bad-ink); background: var(--bad-light); }

/* --- Sales tasks (SalesTaskPanel.razor, Followups.razor) --------------- */
/* Reuses the .act-* layout above on purpose: the two panels sit on the same
   pages and do the same shape of job, so only what genuinely differs is new.
   The tick is the whole interaction — big enough to hit on a phone, and it
   reads as an empty checkbox rather than a button so its meaning is obvious. */
.tsk-tick {
    flex: 0 0 auto; width: 20px; height: 20px; margin-top: 1px;
    border: 1.5px solid var(--line); border-radius: 5px;
    background: var(--card); cursor: pointer; padding: 0;
    position: relative;
}
/* The visible box stays 20px so it lines up with the first line of text, but the
   TAPPABLE area is 42px — measured at 20x20, which is roughly half a fingertip
   and this is the primary action on the Follow-ups page on a phone. Expanding
   the hit box this way costs no layout height. */
.tsk-tick::before { content: ""; position: absolute; inset: -11px; }
.tsk-tick:hover { border-color: var(--redline); background: var(--surface); }
.tsk-tick:hover::after {
    content: "✓"; display: block; font-size: 12px; line-height: 1;
    color: var(--redline); font-weight: 700;
}
.tsk-tick:focus-visible { outline: 2px solid var(--redline); outline-offset: 2px; }

/* The assignee picker is wider than a date, so it gets to grow while the date
   stays its natural size. Both wrap together below 760px via .act-compose-foot. */
.tsk-who { min-width: 11rem; }
.tsk-who :is(input, select) { font-size: var(--fs-xs); }

.tsk-donetoggle {
    border: 0; background: none; cursor: pointer; padding: 6px 0 2px;
    font-size: var(--fs-xs); color: var(--text-muted);
    font-family: var(--font-mono); letter-spacing: .04em;
}
.tsk-donetoggle:hover { color: var(--ink); }
.tsk-done .act-kind { text-decoration: line-through; color: var(--text-muted); }
.tsk-note { font-size: var(--fs-xs); padding-top: 8px; }

/* --- Site log viewer (SiteLogViewer.razor) ---------------------------- */
/* Reading a log used to mean opening the EDIT form or exporting a PDF. This is
   the read view: the photograph leads, because on a site log the photo IS the
   record and everything else annotates it. */
.slv-head {
    display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
    padding-bottom: 10px; border-bottom: 1px solid var(--line); margin-bottom: 12px;
}
.slv-where { font-size: var(--fs-sm); font-weight: 600; }
.slv-weather { font-size: var(--fs-xs); color: var(--text-muted); }

/* The reading view left its 860px dialog for /sitelogs/{id} on 2026-08-12. Everything
   below is unchanged; this is the one rule the page needed, because a modal had a
   MaxWidth and a page does not — `.slv-photo` is `flex: 1 1 260px` with a 380px cap, so
   on a wide monitor the grid would otherwise lay a dozen tiles across a 2560px line and
   the notes under them would run the same width. Same ceiling as the document editors. */
.slv-page { max-width: 1040px; }
.slv-photos { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 14px; }
.slv-photo { margin: 0; flex: 1 1 260px; max-width: 380px; min-width: 0; }
/* ⚠ aspect-ratio, not height:auto — THE TILE MUST BE ITS FINAL SIZE BEFORE THE
   PHOTO ARRIVES. A site log carries up to a dozen full-resolution photos and the
   dialog is already open while they decode, so an auto height grew the modal
   under a cursor that was already moving: the Close / Edit pair at the bottom
   walked down the screen and a committed click landed on whatever slid into its
   place. Reserving the box is the whole fix — the photos then paint INTO a
   layout that never moves.
   `object-fit: cover` was already declared here but was INERT: with height:auto
   the box always matched the image's own ratio, so there was nothing to fit. It
   does real work now, and the trade is a crop on photos that are not 4:3 —
   acceptable because the <a> around each one opens the untouched original.
   max-height is left as a cap; at the 380px max-width the ratio yields 285px, so
   it does not currently bind. */
.slv-photo img {
    display: block; width: 100%; height: auto; aspect-ratio: 4 / 3;
    max-height: 340px; object-fit: cover;
    border-radius: var(--r-sm); border: 1px solid var(--line); background: var(--surface);
}
/* The same box, shimmering, for the window BEFORE the photo list has even come
   back from the database — see SiteLogViewer's _loadingPhotos. That window is a
   server round trip, and it used to render one line of "Loading photos…" text
   that was then replaced by a 285px grid: the biggest jump of the two, and the
   one that happens every single time the dialog opens. */
.slv-photo-skel {
    display: block; width: 100%; aspect-ratio: 4 / 3;
    border-radius: var(--r-sm); border: 1px solid var(--line);
    background: linear-gradient(90deg, var(--fill-soft) 25%, var(--fill) 37%, var(--fill-soft) 63%);
    background-size: 400% 100%;
    animation: skel-shimmer 1.4s ease infinite;
}
.slv-photo figcaption {
    margin-top: 5px; font-size: var(--fs-2xs); line-height: 1.35; color: var(--text-muted);
}
/* Anything the machine wrote wears the violet — the app-wide rule. */
.slv-ai {
    font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .06em;
    color: var(--ai); border: 1px solid var(--ai); border-radius: 3px;
    padding: 0 3px; margin-right: 4px; vertical-align: 1px;
}
.slv-block { margin-bottom: 14px; }
.slv-label {
    font-family: var(--font-mono); font-size: var(--fs-2xs); letter-spacing: .08em;
    text-transform: uppercase; color: var(--text-muted); margin-bottom: 4px;
}
.slv-notes { font-size: var(--fs-sm); white-space: pre-wrap; overflow-wrap: anywhere; }
.slv-aibox {
    background: var(--ai-tint); border: 1px solid var(--ai);
    border-radius: var(--r-sm); padding: 10px 12px; font-size: var(--fs-sm);
}
.slv-list { margin: 6px 0 0 16px; font-size: var(--fs-xs); }
.slv-safety { margin-top: 6px; font-size: var(--fs-xs); font-weight: 600; }
.slv-meta { font-size: var(--fs-sm); color: var(--text-muted); }
.slv-toggle {
    border: 0; background: none; cursor: pointer; padding: 8px 0 2px;
    font-family: var(--font-mono); font-size: var(--fs-2xs); letter-spacing: .04em;
    color: var(--text-muted);
}
.slv-toggle:hover { color: var(--ink); }
.slv-forensics { font-size: var(--fs-xs); }
.slv-forensics > div {
    display: flex; flex-wrap: wrap; gap: 4px 10px;
    padding: 4px 0; border-bottom: 1px dashed var(--line);
}
.slv-forensics > div > span { color: var(--text-muted); flex: 0 0 11rem; }
.slv-forensics b { overflow-wrap: anywhere; min-width: 0; }
.slv-actions {
    display: flex; justify-content: flex-end; gap: 8px;
    padding-top: 12px; border-top: 1px solid var(--line);
}

/* A log row is a link to its own record — say so on hover. */
.tbl tr.slv-rowlink { cursor: pointer; }
.tbl tr.slv-rowlink:hover > td { background: var(--surface); }

/* Picking the entries a report covers. The bar only exists while something is
   selected, so it never takes space from the table it sits above. */
.slv-selbar {
    display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
    margin: 8px 0 0; padding: 8px 10px;
    background: var(--surface); border: 1px solid var(--line);
    border-radius: var(--r-sm); font-size: var(--fs-sm);
}
.tbl tr.slv-picked > td { background: var(--accent-tint); }

/* --- The job spine: Lead → Estimate → Job ----------------------------- */
/* Sits under the title block on the lead / estimate / project hubs (JobSpine
   .razor). Steps that don't exist yet render dashed and muted rather than
   vanishing — the missing link is the information. Chips, not breadcrumbs: the
   top bar already owns the breadcrumb, and this is a relationship, not a path. */
.bp-spine {
    display: flex; align-items: center; flex-wrap: wrap;
    gap: 6px; margin: -4px 2px 12px;
}
.bp-spine-step {
    display: inline-flex; align-items: center; gap: 6px;
    padding: 3px 9px; border-radius: var(--r-pill);
    border: 1px solid var(--line); background: var(--card);
    color: var(--text-muted); text-decoration: none;
    font-size: var(--fs-xs); white-space: nowrap;
}
a.bp-spine-step:hover { color: var(--navy); border-color: var(--line-mid); }
/* The hub you are on. Same treatment as .bp-chip.on, so "you are here" reads
   identically to "this filter is active". */
.bp-spine-step.on {
    background: var(--sky-light); border-color: var(--sky);
    color: var(--navy); font-weight: 600;
}
.bp-spine-step.none { border-style: dashed; }
.bp-spine-lb {
    font-family: var(--font-mono); font-size: 9.5px; font-weight: 600;
    text-transform: uppercase; letter-spacing: 0.14em; color: var(--ink3);
}
.bp-spine-sep { color: var(--glyph-ui); font-size: 11px; }

.bp-stepcard { padding: 14px 16px 10px; margin-bottom: 12px; }
.bp-stepper { display: flex; align-items: flex-start; gap: 8px; overflow-x: auto; padding-bottom: 4px; }
.bp-step { display: flex; align-items: center; gap: 8px; flex: none; }
.bp-step-c {
    width: 24px; height: 24px; border-radius: 50%; flex: none;
    display: inline-flex; align-items: center; justify-content: center;
    font-size: 11px; font-weight: 700;
    border: 1.5px solid var(--line-mid); color: var(--text-muted); background: var(--card);
}
.bp-step.done .bp-step-c { background: var(--ok-light); border-color: var(--ok); color: var(--ok-ink); }
.bp-step.now  .bp-step-c { border-color: var(--sky); color: var(--sky); box-shadow: 0 0 0 3px var(--sky-light); }
.bp-step-lb { font-size: var(--fs-xs); font-weight: 650; color: var(--navy); line-height: 1.25; white-space: nowrap; }
.bp-step.todo .bp-step-lb { color: var(--text-muted); font-weight: 500; }
.bp-step-lb small { display: block; font-size: var(--fs-2xs); font-weight: 500; color: var(--text-muted); }
.bp-conn { flex: 1 1 24px; min-width: 16px; height: 2px; border-radius: 2px; background: var(--line); margin-top: 11px; }
.bp-conn.fill { background: var(--ok); }
.bp-derived { margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--line-soft); font-size: var(--fs-2xs); color: var(--ok-ink); }

.bp-tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--line); margin: 14px 0 12px; overflow-x: auto; }
.bp-tabs button {
    border: none; background: transparent; cursor: pointer;
    font-family: var(--font-sans); font-size: var(--fs-sm); font-weight: 600;
    color: var(--text-muted); padding: 8px 14px; border-bottom: 2px solid transparent;
    margin-bottom: -1px; white-space: nowrap;
}
.bp-tabs button:hover { color: var(--navy); }
.bp-tabs button.on { color: var(--navy); border-bottom-color: var(--sky); }

/* --- BLUEPRINT Get-paid (Sheet 03) ---------------------------------------- */
.bp-chips { display: flex; gap: 6px; flex-wrap: wrap; margin: 12px 0; }
.bp-chip {
    border: 1px solid var(--line);
    background: var(--card);
    color: var(--text-muted);
    font-family: var(--font-sans);
    font-size: var(--fs-xs);
    font-weight: 600;
    padding: 5px 12px;
    border-radius: var(--r-pill);
    cursor: pointer;
    white-space: nowrap;
}
/* :visited twin — (0,2,0) beats `a, a:visited` (0,1,1) in both themes and in any
   source order. Mandatory for any colour rule that can land on an <a>; pinned by
   VisitedCascadeTests, which sweeps the whole sheet rather than an allowlist. */
.bp-chip, .bp-chip:visited { color: var(--text-muted); }
.bp-chip:hover { color: var(--navy); border-color: var(--line-mid); }
.bp-chip.on { background: var(--sky-light); border-color: var(--sky); color: var(--navy); }
.bp-chip.chip-bad { background: var(--bad-light); border-color: var(--bad); color: var(--bad-ink); }

/* Collapsible chip groups. A page with a dozen filters (Pipeline, Projects)
   would push its content four rows down on a phone, so each long group hides
   behind its own chip there. `display: contents` keeps the chips in the
   parent's flex flow, so desktop looks exactly as if the group weren't there. */
.chip-group { display: contents; }
.chip-more { display: none; }
.chip-more-caret { font-size: 9px; line-height: 1; opacity: 0.7; }
@media (max-width: 760px) {
    .chip-more { display: inline-flex; align-items: center; gap: 5px; }
    .chip-group:not(.open) { display: none; }
}
.bp-bigamt { font-size: 26px; font-weight: 750; letter-spacing: -0.02em; color: var(--navy); line-height: 1.1; }
.bp-open-sub { display: block; font-size: var(--fs-2xs); color: var(--warn-ink); font-weight: 600; }
.bp-remind {
    margin-top: 10px; padding: 10px 12px;
    border: 1px dashed var(--line-mid); border-radius: var(--r-sm);
    font-size: var(--fs-xs); color: var(--text-muted); line-height: 1.55;
}
.bp-remind b { color: var(--navy); }
.bp-paynote { margin-top: 8px; font-size: var(--fs-2xs); color: var(--text-muted); }
.bp-paynote b { color: var(--ok-ink); }

/* --- BLUEPRINT narrated tiles ------------------------------------------- */
.bp-tile { display: flex; flex-direction: column; gap: 2px; }
.bp-tile .kpi-label { margin-top: 0; }
.bp-tile-sub { font-size: var(--fs-xs); color: var(--text-muted); line-height: 1.45; }   /* mockup .tile .s */

/* Rail footer: Settings pinned to the bottom (Blueprint navfoot). */
.navrail-spacer { flex: 1; }
.navrail a.navrail-foot { color: var(--text-muted); font-weight: 500; }

/* --- BLUEPRINT rail icons ----------------------------------------------- */
.nav-i { flex: none; color: var(--text-muted); }
.navrail a, .navrail-glabel { display: inline-flex; align-items: center; gap: 9px; }
.navrail-glabel { min-width: 0; }
.navrail a.active .nav-i { color: var(--sky); }   /* the active icon wears the redline */
.navrail a.navrail-top .nav-i { color: var(--text-muted); }
.navrail a.navrail-top.active .nav-i { color: var(--sky); }

/* --- BLUEPRINT top bar -------------------------------------------------- */
.tb-i { flex: none; display: inline-block; vertical-align: -2px; }
.topbar-new { position: relative; }
.topbar-new-btn { display: inline-flex; align-items: center; gap: 6px; }
.topbar-new-menu {
    position: absolute;
    right: 0;
    top: calc(100% + 6px);
    min-width: 180px;
    z-index: 300;
}
/* Mockup .askai: AI wears the violet tint — the one non-redline chip up here. */
.askai-chip {
    display: inline-flex;
    align-items: center;
    gap: 7px;
    padding: 7.5px 12px;
    border: 1px solid var(--line);
    border-radius: var(--r-sm);
    background: var(--stage-materials-bg);
    color: var(--stage-materials-fg);
    font-size: 12.5px;
    font-weight: 600;
    text-decoration: none;
    white-space: nowrap;
    /* It is a <button> now (it opens the Ask Tractor slide-over; it used to be
       an <a href="/ask">, a route nothing declares). A button inherits neither
       the page font nor a pointer, so both have to be said out loud. */
    font-family: inherit;
    line-height: 1.2;
    cursor: pointer;
}
.askai-chip:hover { border-color: var(--stage-materials-fg); }

/* Mockup .iconbtn: the bell is a 34px hairline square, not a bare glyph. */
.topbar-inbox .topbar-user-btn {
    width: 34px;
    height: 34px;
    justify-content: center;
    padding: 0;
    border: 1px solid var(--line);
    border-radius: var(--r-sm);
    background: var(--card);
    color: var(--navy-soft);
}
.topbar-inbox .topbar-user-btn:hover,
.topbar-inbox .topbar-user-btn.open { background: var(--paper-warm); color: var(--navy); }

.topbar-who { display: flex; flex-direction: column; line-height: 1.25; text-align: right; }
.topbar-who b { font-size: 12.5px; font-weight: 650; color: var(--navy); }
.topbar-who span { font-size: 12.5px; color: var(--text-muted); }
@media (max-width: 900px) { .topbar-who, .askai-chip { display: none; } }

/* --- BLUEPRINT home (Sheet 01) ----------------------------------------- */
/* 12px below the greeting, the same trailing space .bp-projhead leaves on every
   other page — Home's title block was the one at 14. See .home-strip: the whole
   Home stack is one 12px rhythm. */
.bp-greet { padding: 6px 2px 12px; }
.bp-greet h1 {
    font-size: 21px;       /* the greeting is the mockup's .h1, same as every page title */
    font-weight: 700;
    letter-spacing: -0.01em;
    color: var(--navy);
    margin: 0;
    line-height: 1.15;
}
.bp-greet-sub { margin-top: 3px; font-size: var(--fs-sm); color: var(--text-muted); }

.bp-cols { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 14px; margin-top: 12px; }
/* ⚠⚠ minmax(0, 1fr), NOT a bare 1fr — the base rule above already knew that and the
   stacked override below did not, for as long as both have existed.
   A grid item defaults to `min-width: auto`, which is its MIN-CONTENT width, and a bare
   `1fr` track cannot force it smaller. So a column holding a wide table stopped shrinking
   at the table's natural width: measured on /orders/{id} at 360px, the left column was
   518px inside a 344px grid and .content-scroll's scrollWidth was 526 against a 360 client
   width — the whole page scrolled sideways and every card ran off the right edge.
   ⚠ The `overflow-x: auto` on .tbl-wrap is what SHOULD absorb this (an element with
   overflow != visible has an automatic minimum of zero, so it may shrink and scroll its
   own content). It never got the chance: its ancestor was already too wide. Making the
   track minmax(0, …) is what lets the shrink reach it.
   ⚠ Shared: .bp-cols is the estimate editor, the invoice detail, the order detail and
   Home. Every one of them was one wide table away from this. */
@media (max-width: 1100px) { .bp-cols { grid-template-columns: minmax(0, 1fr); } }

.bp-card { padding: 14px 16px; }
.bp-card-h { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.bp-card-hint { margin-left: auto; font-size: var(--fs-2xs); color: var(--text-muted); }

/* Exception rows: severity dot · one sentence · the way in. */
.bp-xrow {
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 9px 8px;
    margin: 0 -8px;
    border-radius: var(--r-sm);
    border-bottom: 1px solid var(--line-soft);
    color: var(--navy);
    font-size: var(--fs-sm);
    text-decoration: none;
}
.bp-xrow:last-of-type { border-bottom: none; }
.bp-xrow:hover { background: var(--sky-soft); }
.bp-xrow-txt { flex: 1; min-width: 0; }
.bp-xrow-go { flex: none; font-size: var(--fs-2xs); font-weight: 600; color: var(--sky); opacity: 0; transition: opacity 0.12s; }
.bp-xrow:hover .bp-xrow-go { opacity: 1; }
.bp-sdot { flex: none; width: 8px; height: 8px; border-radius: 50%; }
.bp-sdot.bad  { background: var(--bad); }
.bp-sdot.warn { background: var(--warn); }
.bp-sdot.info { background: var(--line-mid); }   /* same grey the bell uses for Info */
.bp-allclear { padding: 14px 4px; font-size: var(--fs-sm); color: var(--text-muted); }

/* Pipeline strip: stage cells joined by chevrons; "now" wears the redline. */
.bp-pipe { display: flex; align-items: stretch; gap: 8px; }
.bp-pcell {
    flex: 1;
    min-width: 0;
    border: 1px solid var(--line);
    border-radius: var(--r-sm);
    padding: 10px 12px;
    text-decoration: none;
    display: flex;
    flex-direction: column;
    gap: 2px;
    background: var(--card);
}
.bp-pcell:hover { border-color: var(--line-mid); background: var(--paper-cool); }
.bp-pcell.now { border-color: var(--sky); box-shadow: inset 0 2px 0 var(--sky); }
.bp-pval { font-size: 20px; font-weight: 700; color: var(--navy); letter-spacing: -0.01em; }
.bp-psub { font-size: var(--fs-2xs); color: var(--text-muted); }
.bp-parrow { align-self: center; color: var(--glyph-ui); font-size: 16px; flex: none; }
@media (max-width: 760px) { .bp-pipe { flex-direction: column; } .bp-parrow { display: none; } }

/* "Around the shop": quiet count rows — a directory, not a chart gallery. */
.bp-mini {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 10px;
    padding: 8px 8px;
    margin: 0 -8px;
    border-radius: var(--r-sm);
    border-bottom: 1px solid var(--line-soft);
    font-size: var(--fs-sm);
    color: var(--navy);
    text-decoration: none;
}
.bp-mini:last-of-type { border-bottom: none; }
.bp-mini:hover { background: var(--sky-soft); }
.bp-mini-val { font-weight: 700; }

.bg-fill     { background: var(--fill); }
.bg-warm     { background: var(--paper-warm); }

.fw-600 { font-weight: 600; }
.fw-700 { font-weight: 700; }

/* Numeric table cells/headers — right-aligned (replaces the repeated
   inline style="text-align:right;"). The th variant needs the extra
   specificity to beat `table.tbl thead th { text-align:left }`. */
.num,
table.tbl thead th.num,
table.tbl tbody td.num { text-align: right; }

/* --- Detail-page tiles ------------------------------------------------
   Object detail pages stack their content boxes in a single column by
   default. On wider viewports flow them into balanced columns (masonry) so
   the page fills its width instead of one long vertical scroll; collapses
   back to one column when the viewport is narrow.

   NOTE: .content-scroll sits in a column flexbox and uses margin-right:auto,
   which disables flex-stretch and shrink-to-fits it to its content width. For
   narrow pages that keeps the content in a tidy left column, but any content that
   is WIDE and self-scrolling breaks: shrink-to-fit grows .content-scroll to the
   content's full width, it overflows the fixed panes (which clip via
   overflow:hidden), and the inner region never gets to scroll — leaving detail
   tiles pinned to one column, and data tables (.tbl-wrap) / the pipeline kanban
   board (.kanban-board) clipped off the right edge between the 760px stack
   breakpoint and the width where they'd fit. Restoring stretch on those pins the
   scroll area to the viewport so the tiles spread and the table / board scroll. */
.content-scroll:has(.detail-tiles),
.content-scroll:has(.tbl-wrap),
.content-scroll:has(.kanban-board),
.content-scroll:has(.boards-board) {
    margin-right: 16px;
    width: auto;
    align-self: stretch;
}

/* The board's pane is a column: header + chips take their natural height and
   the board flexes into the rest (see .kanban-board's flex:1). */
.content-scroll:has(.kanban-board),
.content-scroll:has(.boards-board) { display: flex; flex-direction: column; }

.detail-tiles {
    width: 100%;
    columns: 420px;
    column-gap: 12px;
}
.detail-tiles > * {
    -webkit-column-break-inside: avoid;
    break-inside: avoid;
    margin-bottom: 12px;
}

/* Project-page sections (Overview / Money / Field / Client & Closeout) and the
   accordion line view: every card is a full-width row that expands in place. */
.tile-section-head {
    margin: 20px 2px 8px;
    font-size: var(--fs-2xs);
    font-weight: 700;
    letter-spacing: .09em;
    text-transform: uppercase;
    color: var(--navy-soft);
}
.acc-list {
    display: flex;
    flex-direction: column;
    gap: 8px;
    margin-bottom: 4px;
}
.acc-tile { padding: 0; }
/* ⚠ THIS IS A <button>, AND IT HAS TO BE — 2026-08-15.
   These ten rows were `<div class="acc-head" role="button" tabindex="0" @onclick>`
   with NO key handler. role+tabindex buys the focus stop and the announcement and
   nothing else: a keyboard or switch user could tab to every section header on
   ProjectDetail — the app's primary record hub — see it focused, press Enter or
   Space, and get nothing at all. Ten sections, none of them openable. WCAG 2.1.1
   Level A.
   A real <button> brings Enter/Space, the role, the focus stop and the disabled
   semantics for free, which is exactly why SharedUI/Fold.razor is one. The four
   properties below are the button reset that turns the UA's chrome back off —
   .fold-head (app.css ~4651) carries the same four for the same reason. Without
   them the rows render with a grey UA background, a bevel and centred text. */
.acc-head {
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 11px 16px;
    cursor: pointer;
    border-radius: var(--r);
    transition: background .12s ease;
    user-select: none;
    width: 100%;
    border: none;
    background: transparent;
    text-align: left;
    color: inherit;
}
.acc-head:hover,
.acc-head:focus-visible { background: var(--paper-warm); }
.acc-title {
    font-size: var(--fs-2xs);
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.12em;
    color: var(--text-muted);
}
.acc-caret {
    color: var(--navy-soft);
    font-size: var(--fs-2xs);
    width: 14px;
    text-align: center;
}
.acc-body { padding: 0 16px 14px; }

/* ---- Ask Tractor: the global floating copilot -------------------------------
   Frosted slide-over in the same language as the mobile nav pill (translucent
   paper + backdrop blur, near-black accent). The FAB sits above the mobile
   bottom nav; the panel fills most of the phone screen, a card on desktop. */
.askw-fab {
    position: fixed;
    right: 22px;
    bottom: 22px;
    z-index: 880;
    width: 52px;
    height: 52px;
    border-radius: 50%;
    border: 1px solid var(--navy);
    background: var(--navy);
    color: var(--card);
    font-size: 20px;
    line-height: 1;
    cursor: pointer;
    box-shadow: 0 8px 24px rgba(15, 18, 24, .28);
    transition: transform .16s ease, box-shadow .16s ease, background .16s ease;
}
.askw-fab:hover { transform: translateY(-2px); box-shadow: 0 12px 30px rgba(15, 18, 24, .34); }
.askw-fab.open { background: var(--card); color: var(--navy); }

.askw-panel {
    position: fixed;
    right: 22px;
    bottom: 86px;
    z-index: 890;
    display: flex;
    flex-direction: column;
    width: min(430px, calc(100vw - 32px));
    height: min(640px, calc(100dvh - 130px));
    border: 1px solid var(--line);
    border-radius: 18px;
    background: color-mix(in srgb, var(--card) 82%, transparent);
    -webkit-backdrop-filter: blur(18px) saturate(1.4);
    backdrop-filter: blur(18px) saturate(1.4);
    box-shadow: 0 24px 64px rgba(15, 18, 24, .30);
    overflow: hidden;
    animation: askw-in .18s ease;
}
@keyframes askw-in {
    from { opacity: 0; transform: translateY(10px) scale(.98); }
    to   { opacity: 1; transform: none; }
}
.askw-head {
    display: flex;
    align-items: flex-start;
    gap: 10px;
    padding: 14px 16px 10px;
    border-bottom: 1px solid var(--line);
}
.askw-title { font-weight: 700; color: var(--navy); }
.askw-sub { font-size: var(--fs-2xs); color: var(--navy-soft); margin-top: 1px; }
.askw-head-actions { margin-left: auto; display: flex; gap: 6px; }
.askw-icon-btn {
    width: 28px; height: 28px;
    border: 1px solid var(--line);
    border-radius: 8px;
    background: transparent;
    color: var(--navy-soft);
    cursor: pointer;
    font-size: 13px;
    line-height: 1;
}
.askw-icon-btn:hover { color: var(--navy); border-color: var(--navy-soft); }

.askw-thread {
    flex: 1;
    overflow-y: auto;
    display: flex;
    flex-direction: column;
    gap: 10px;
    padding: 14px 16px;
}
.askw-msg {
    max-width: 86%;
    padding: 10px 13px;
    border-radius: 14px;
    font-size: var(--fs-sm);
    line-height: 1.55;
    white-space: pre-wrap;
    overflow-wrap: anywhere;
}
.askw-user {
    align-self: flex-end;
    background: var(--navy);
    color: var(--card);
    border-bottom-right-radius: 5px;
}
.askw-assistant {
    align-self: flex-start;
    background: var(--card);
    border: 1px solid var(--line);
    border-bottom-left-radius: 5px;
    white-space: normal;
}
.askw-assistant ul { margin: 4px 0; padding-left: 18px; }
.askw-assistant div + div { margin-top: 4px; }
.askw-link {
    color: var(--sky);
    font-weight: 600;
    text-decoration: none;
    border-bottom: 1px solid color-mix(in srgb, var(--sky) 40%, transparent);
}
.askw-link:hover { border-bottom-color: var(--sky); }

.askw-typing { display: flex; gap: 4px; align-items: center; padding: 12px 14px; }
.askw-typing span {
    width: 6px; height: 6px; border-radius: 50%;
    background: var(--navy-soft);
    animation: askw-dot 1.1s infinite ease-in-out;
}
.askw-typing span:nth-child(2) { animation-delay: .15s; }
.askw-typing span:nth-child(3) { animation-delay: .3s; }
@keyframes askw-dot { 0%, 60%, 100% { opacity: .35; transform: none; } 30% { opacity: 1; transform: translateY(-3px); } }

.askw-empty { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 26px 8px 10px; text-align: center; }
.askw-empty-icon { font-size: 26px; color: var(--navy-soft); }
.askw-empty-title { font-weight: 600; color: var(--navy); }
.askw-chips { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; margin-top: 6px; }
.askw-chip {
    border: 1px solid var(--line);
    border-radius: 999px;
    background: var(--card);
    color: var(--navy);
    font-size: var(--fs-2xs);
    font-weight: 600;
    padding: 7px 12px;
    cursor: pointer;
    transition: border-color .12s ease, background .12s ease;
}
.askw-chip:hover { border-color: var(--navy-soft); background: var(--paper-warm); }

.askw-input-row { display: flex; gap: 8px; padding: 10px 12px 6px; border-top: 1px solid var(--line); }
.askw-input {
    flex: 1;
    padding: 10px 14px;
    border: 1px solid var(--line);
    border-radius: 999px;
    background: var(--card);
    font-size: var(--fs-sm);
}
.askw-input:focus { outline: none; border-color: var(--navy-soft); }
.askw-send {
    width: 38px; height: 38px;
    border-radius: 50%;
    border: 1px solid var(--navy);
    background: var(--navy);
    color: var(--card);
    font-size: 16px;
    cursor: pointer;
}
.askw-send:disabled { opacity: .4; cursor: default; }
.askw-foot { padding: 0 16px 10px; font-size: 10px; color: var(--navy-soft); text-align: center; }

@media (max-width: 760px) {
    /* Sit above the floating bottom nav pill. */
    .askw-fab { right: 14px; bottom: calc(86px + env(safe-area-inset-bottom)); }
    .askw-panel {
        right: 8px;
        left: 8px;
        width: auto;
        bottom: calc(148px + env(safe-area-inset-bottom));
        height: min(560px, calc(100dvh - 220px));
    }
}

/* Project activity timeline (ProjectActivityPanel + portal flavor). */
.act-list { display: flex; flex-direction: column; gap: 2px; margin-top: 6px; }
.act-day {
    font-size: var(--fs-2xs);
    font-weight: 700;
    color: var(--navy-soft);
    text-transform: uppercase;
    letter-spacing: .06em;
    margin: 10px 0 4px;
}
.act-day:first-child { margin-top: 0; }
.act-row {
    display: flex;
    align-items: baseline;
    gap: 8px;
    padding: 4px 2px;
    border-bottom: 1px solid var(--line-soft, var(--line));
}
.act-row:last-child { border-bottom: none; }
.act-icon { flex: 0 0 20px; text-align: center; }
.act-main { flex: 1; min-width: 0; }

/* Blueprint card-header voice: the letterspaced micro-cap that opens every
   card in the mockup (same register as table headers — labels, not body). */
/* Mockup .card-h .microcap — the drawing-title-block caption: mono, 10.5px,
   .16em tracking, on the muted ink. */
.section-header {
    font-family: var(--font-mono);
    font-size: var(--fs-2xs);   /* was 10.5px — see the micro-label floor note in :root */
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.16em;
    color: var(--text-muted);
    margin-bottom: 10px;
    padding-bottom: 8px;
    border-bottom: 1px solid var(--line-soft);
}

/* --- Collapsible section header --------------------------------------- */
.section-toggle {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    padding: 2px 0;
    background: none;
    border: none;
    cursor: pointer;
    color: inherit;
}
.section-caret {
    display: inline-block;
    width: 12px;
    text-align: center;
    font-size: 14px;
    color: var(--glyph-ui);
    transition: transform 0.15s ease;
}
.section-caret.collapsed { transform: rotate(-90deg); }
.section-toggle:hover .section-caret { color: var(--navy); }

/* Punch-list room header rows (project panel). */
.punch-room {
    font-size: var(--fs-2xs);
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: var(--navy-soft);
    background: var(--paper-warm);
    padding: 5px 8px !important;
}

/* Small inline action inside a field label ("+ new" next to Customer). */
.field-inline-link {
    border: none;
    background: transparent;
    color: var(--chart-blue);
    font-size: var(--fs-2xs);
    font-weight: 600;
    cursor: pointer;
    padding: 0 2px;
    margin-left: 6px;
}
.field-inline-link:hover { text-decoration: underline; }

/* --- Empty states ------------------------------------------------------ */
/* Guided true-empty view (EmptyState.razor): what the page is for + one CTA. */
.empty-state {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 6px;
    padding: 40px 16px;
    text-align: center;
}
.empty-state-icon  { font-size: 30px; line-height: 1; opacity: 0.9; }
.empty-state-title { font-size: var(--fs-md); font-weight: 700; color: var(--navy); }
.empty-state-sub   { font-size: var(--fs-sm); color: var(--text-muted); max-width: 440px; }
.empty-state-actions { margin-top: 10px; display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }

/* --- Skeleton loading --------------------------------------------------- */
/* Placeholder shapes while data loads (SkeletonRows.razor). The shimmer runs
   between two surface tokens so it works in both themes. */
.skel {
    display: inline-block;
    width: 100%;
    height: 12px;
    border-radius: 4px;
    background: linear-gradient(90deg, var(--fill-soft) 25%, var(--fill) 37%, var(--fill-soft) 63%);
    background-size: 400% 100%;
    animation: skel-shimmer 1.4s ease infinite;
}
@keyframes skel-shimmer {
    0%   { background-position: 100% 0; }
    100% { background-position: 0 0; }
}
.skel-row td { padding: 13px 8px; }
/* Vary widths so the placeholder reads organic, not striped. */
.skel-row td:nth-child(3n)   .skel { width: 55%; }
.skel-row td:nth-child(3n+1) .skel { width: 80%; }
.skel-row td:nth-child(4n)   .skel { width: 40%; }
/* Card-collapsed tables (mobile) — skeleton rows keep a simple two-up shape. */
@media (max-width: 760px) {
    .tbl-cards tbody tr.skel-row { display: grid; grid-template-columns: 1fr 1fr; }
}

/* --- Image Placeholder ------------------------------------------------ */
.img-placeholder {
    background: repeating-linear-gradient(
        45deg,
        var(--fill) 0px,
        var(--fill) 6px,
        var(--card) 6px,
        var(--card) 12px
    );
    border: 1px solid var(--line);
    border-radius: var(--r-sm);
    display: flex;
    align-items: center;
    justify-content: center;
    color: var(--text-muted);
    font-size: var(--fs-2xs);
    font-family: var(--font-mono);
    text-align: center;
}

/* --- Order Flow Cards ------------------------------------------------- */
.order-card {
    border: 1px solid var(--line);
    border-radius: var(--r);
    background: var(--card);
    box-shadow: var(--shadow-soft);
    padding: 10px;
}

.order-flow {
    display: flex;
    gap: 0;
    margin-top: 6px;
}

.order-step {
    flex: 1;
    text-align: center;
    position: relative;
}

.order-step-circle {
    width: 20px;
    height: 20px;
    border-radius: 50%;
    border: 1.5px solid var(--line-mid);
    background: var(--card);
    margin: 0 auto;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: var(--fs-2xs);   /* was 7px — floored to the smallest scale step */
    font-weight: 700;
    color: var(--navy-soft);
    position: relative;
    z-index: 1;
}

.order-step-circle.done    { background: var(--ok); border-color: var(--ok); color: #fff; }
.order-step-circle.current { background: var(--sky); border-color: var(--sky); color: #fff; }

/* The strip is the thing that LOOKS like an order's status, so it is the thing
   that should set it. When the user may manage orders the circle is a real
   <button>: a button does not inherit font-family, and the global
   `:focus-visible { outline: none }` above would leave it with no focus ring
   at all — both restored here rather than with inline styles, which cannot
   express :focus-visible. */
button.order-step-circle { font-family: inherit; cursor: pointer; }
button.order-step-circle:disabled { cursor: default; }
/* A 20px circle changing its border colour is far too quiet to read as "this
   is clickable" — the ring is what actually lands at that size. Solid --sky,
   NOT --sky-soft (5-8% alpha, invisible at 3px) and NOT color-mix, which is
   dropped whole where unsupported and would take the border with it. */
button.order-step-circle:not(:disabled):hover {
    border-color: var(--sky);
    box-shadow: 0 0 0 2px var(--sky);
}
button.order-step-circle:focus-visible {
    outline: 2px solid var(--sky);
    outline-offset: 2px;
}

.order-step-label {
    font-size: var(--fs-2xs);   /* was 8px — floored to the smallest scale step */
    color: var(--navy-soft);
    margin-top: 3px;
}

.order-step-line {
    position: absolute;
    top: 10px;
    left: calc(50% + 10px);
    right: calc(-50% + 10px);
    height: 1.5px;
    background: var(--line);
}

.order-step-line.done { background: var(--ok); }

/* --- Error page ------------------------------------------------------- */
.error-page {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    height: 100%;
    gap: 12px;
    color: var(--navy-soft);
}

/* --- Buttons (extra variants) ---------------------------------------- */
.btn-danger {
    background: var(--bad);
    color: #fff;
    border-color: var(--bad);
}
/* Solid first, mixed second: without color-mix the whole declaration is
   dropped and `.btn:hover`'s pale --fill would win on specificity, leaving
   white text on a near-white Delete button. */
.btn-danger:hover {
    background: var(--bad);
    background: color-mix(in srgb, var(--bad) 82%, var(--ink));
    border-color: var(--bad);
    border-color: color-mix(in srgb, var(--bad) 82%, var(--ink));
}

/* Body of a ConfirmButton dialog — the sentence naming the record the action is
   about to destroy. Sized down from the dialog default because it is read, not
   scanned, and the record's own identifiers (<b>, .mono) need to out-weigh it. */
.confirm-detail {
    font-size: var(--fs-sm);
    color: var(--navy-soft);
    line-height: 1.45;
}
.confirm-detail b { color: var(--navy); }
.confirm-detail p { margin: 0 0 8px; }
.confirm-detail p:last-child { margin-bottom: 0; }

/* --- Modal ------------------------------------------------------------ */
.modal-overlay {
    position: fixed;
    inset: 0;
    background: rgba(31, 56, 100, 0.32);
    display: flex;
    align-items: flex-start;
    justify-content: center;
    /*  ⚠⚠ A DIALOG MUST NOT INHERIT TYPOGRAPHY FROM WHEREVER ITS TRIGGER SAT.
        `position: fixed` takes the overlay out of the table VISUALLY, and nothing takes
        it out of the CASCADE: it is still a DOM child of the cell holding the button,
        because ConfirmButton renders as a sibling of its trigger (deliberately — see
        that component's note).

        Measured 2026-08-28 on the timesheet's delete confirmation, whose trigger lives in
        `<td style="text-align:right; white-space:nowrap">`: the card computed
        `white-space: nowrap` and `text-align: right`, so its sentences could not wrap and
        **overflowed the 440px card to 999px — 559px of text outside the box** — while
        every line sat hard right. Both halves of "the text goes out of the box and
        nothing is centered" came from these two inherited declarations.

        ⚠ Reset on the OVERLAY, not the card, so nothing inside can inherit either.
        ⚠ Deliberately NOT resetting font-size, colour or line-height. Those inherit too
        and a small or tinted cell would leak them — but changing them here would restyle
        every existing dialog, and this fixes a LAYOUT fault. Add them only with a
        measurement showing a dialog that actually reads wrong.                        */
    white-space: normal;
    text-align: left;
    text-transform: none;
    letter-spacing: normal;
    padding: 60px 16px 16px;
    z-index: 1000;
    overflow-y: auto;
}

.modal-card {
    width: 100%;
    background: var(--card);
    border: 1px solid var(--line);
    border-radius: var(--r);
    box-shadow: var(--shadow-md);
    display: flex;
    flex-direction: column;
    animation: modal-in 0.12s ease-out;
}

@keyframes modal-in {
    from { opacity: 0; transform: translateY(-8px); }
    to   { opacity: 1; transform: translateY(0); }
}

.modal-header {
    display: flex;
    align-items: center;
    padding: 12px 14px;
    border-bottom: 1px solid var(--line);
}

.modal-title {
    font-weight: 700;
    font-size: var(--fs-md);
    color: var(--navy);
    flex: 1;
}

.modal-close {
    border: none;
    background: transparent;
    font-size: 14px;
    color: var(--navy-soft);
    cursor: pointer;
    line-height: 1;
    padding: 2px 4px;
    border-radius: var(--r-xs);
}
.modal-close:hover { background: var(--fill); }

.modal-body { padding: 14px; }

.modal-footer {
    display: flex;
    justify-content: flex-end;
    gap: 8px;
    padding: 12px 14px;
    border-top: 1px solid var(--line);
}
/* Footer status, opposite the buttons. margin-right:auto does the pushing, so
   the buttons keep their flex-end position and nothing else in the footer moves.
   min-width:0 lets a long message ellipsize instead of shoving them off. */
.modal-footer-start {
    margin-right: auto;
    display: flex;
    align-items: center;
    min-width: 0;
}

/* --- Forms ------------------------------------------------------------ */
.form-grid {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 10px 12px;
}

.field { display: flex; flex-direction: column; gap: 3px; }
.field.col-span-2 { grid-column: 1 / -1; }

.field > label {
    font-size: var(--fs-2xs);
    font-weight: 700;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    color: var(--navy-soft);
}

.field input,
.field select,
.field textarea {
    font-family: var(--font-sans);
    font-size: var(--fs-sm);
    color: var(--navy);
    padding: 6px 8px;
    border: 1px solid var(--line);
    border-radius: var(--r-sm);
    background: var(--input-bg);
    outline: none;
    width: 100%;
}

.field input:focus,
.field select:focus,
.field textarea:focus { border-color: var(--sky); box-shadow: 0 0 0 3px var(--sky-soft); }

.field textarea { resize: vertical; min-height: 64px; }

/* --- Search-select (type-to-filter dropdown) -------------------------- */
.search-select { position: relative; width: 100%; }

.search-select-input {
    font-family: var(--font-sans);
    font-size: var(--fs-sm);
    color: var(--navy);
    padding: 6px 24px 6px 8px;
    border: 1px solid var(--line);
    border-radius: var(--r-sm);
    background: var(--input-bg);
    outline: none;
    width: 100%;
    cursor: text;
}

.search-select-input:focus { border-color: var(--sky); box-shadow: 0 0 0 3px var(--sky-soft); }

/* Non-searchable (click-only) selects read like a dropdown, not a text field. */
.search-select-input[readonly] { cursor: pointer; }

.search-select-caret {
    position: absolute;
    top: 50%;
    right: 8px;
    transform: translateY(-50%);
    font-size: 14px;
    color: var(--navy-soft);
    pointer-events: none;
}

.search-select-menu {
    position: absolute;
    top: calc(100% + 2px);
    left: 0;
    right: 0;
    max-height: 200px;
    overflow-y: auto;
    background: var(--card);
    border: 1px solid var(--line);
    border-radius: var(--r-sm);
    box-shadow: var(--shadow-md);
    z-index: 1200;
    padding: 3px;
}

.search-select-option {
    font-size: var(--fs-sm);
    color: var(--navy);
    padding: 5px 8px;
    border-radius: var(--r-sm);
    cursor: pointer;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.search-select-option:hover { background: var(--sky-soft); }
.search-select-option.active { background: var(--sky-soft); }   /* keyboard highlight */
.search-select-option.selected { background: var(--sky-light); font-weight: 600; }

.search-select-empty {
    font-size: var(--fs-sm);
    color: var(--navy-soft);
    padding: 6px 8px;
}

.field-error {
    color: var(--bad);
    font-size: var(--fs-2xs);
    margin-top: 2px;
}

/* Per-field validation state — pages add .invalid to the .field wrapper of
   the offending input alongside the .field-error message. */
.field.invalid > label { color: var(--bad); }
.field.invalid input,
.field.invalid select,
.field.invalid textarea,
.field.invalid .search-select-input {
    border-color: var(--bad);
    box-shadow: 0 0 0 3px var(--bad-light);
}
/* The same state for a whole SECTION, when the refusal is about a group of controls
   rather than one input — "add at least one line" belongs to the line grid, not to any
   field in it. Without this, a page that marked a .box got no visual change at all and
   the only sign of the refusal was the sentence at the top of the page. */
.box.invalid { border-color: var(--bad); box-shadow: 0 0 0 3px var(--bad-light); }

/* --- Flash / inline notice -------------------------------------------- */
/* ⚠⚠ THIS IS A BLOCK, NOT A FLEX ROW — 2026-08-05. Do not "tidy" it back.
   It was `display:flex; align-items:center; gap:8px`, which works for the 105
   flashes whose whole content is one bare string and SHATTERS the other 11.

   In a flex container every contiguous run of text becomes an anonymous flex
   item and every inline element becomes another, so
       <div class="flash"><strong>Paused.</strong> … under <em>Company details</em> …</div>
   lays out as FOUR side-by-side columns with 8px gutters. Measured on
   /company → Integrations: one sentence rendered as a four-cell table.
   It reads as a layout bug rather than a sentence, which is why nobody
   suspected the stylesheet — and a plain-text flash looks perfect, which is
   why it survived 116 call sites.

   Affected files were IntegrationsPanel, PlanViewer, SignatureStatusPanel,
   SiteLogEditor, Timesheets, InventoryBoard, ToolsBoard, Users — and Admins,
   TenantDetail and TenantEdit on the CONTROL PLANE, which shares this sheet.
   Pinned by FlashProseTests. */
.flash {
    display: block;
    position: relative;
    padding: 8px 12px;
    border-radius: var(--r-sm);
    font-size: var(--fs-sm);
    font-weight: 600;
    /* Flashes are now allowed to be real paragraphs, so they need a reading
       line-height. A one-line flash is unchanged by it. */
    line-height: 1.5;
    margin-bottom: 12px;
    border: 1px solid transparent;
}
/* The whole banner is 600, so a <strong> inside it used to be invisible —
   the emphasis the author wrote simply did not render as emphasis. */
.flash strong, .flash b { font-weight: 750; }
/* Only reserve the corner when there is something in it. */
.flash:has(.flash-close) { padding-right: 34px; }
/* Status edges are the status ink at low strength — same recipe as the pills,
   so nothing here is a hand-picked pastel. */
.flash-ok   { background: var(--ok-light);   color: var(--ok-ink); border-color: color-mix(in srgb, var(--ok-ink) 30%, transparent); }
.flash-info { background: var(--sky-soft);   color: var(--navy); border-color: var(--sky-light); }
.flash-warn { background: var(--warn-light); color: var(--warn-ink); border-color: color-mix(in srgb, var(--warn-ink) 30%, transparent); }
/* ⚠ Positioned, not `margin-left:auto` — that was the flex push-right trick and
   it stopped working the moment .flash became a block. Anchored to the top so it
   sits with the FIRST line of a multi-line message, not floating beside the
   middle of a paragraph. */
.flash-close {
    position: absolute;
    right: 6px;
    top: 4px;
    border: none;
    background: transparent;
    cursor: pointer;
    font-size:12px;   /* ✕ glyph */
    color: inherit;
    opacity: 0.7;
}
.flash-close:hover { opacity: 1; }

/* --- Toasts (transient notifications; ToastHost lives in the layout) ---- */
.toast-stack {
    position: fixed;
    right: 16px;
    bottom: 16px;
    z-index: 2000;              /* above modals (1000), below reconnect (3000) */
    display: flex;
    flex-direction: column;
    gap: 8px;
    max-width: 360px;
}
.toast {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 10px 12px;
    border-radius: var(--r-sm);
    border: 1px solid transparent;
    font-size: var(--fs-sm);
    font-weight: 600;
    box-shadow: var(--shadow-md);
    animation: toast-in 0.15s ease-out;
}
@keyframes toast-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
/* Toasts float over arbitrary content: the translucent status tints must
   layer over an opaque paper base or the page bleeds through the message. */
.toast-ok   { background: linear-gradient(var(--ok-light), var(--ok-light)) var(--card);     color: var(--ok-ink); border-color: color-mix(in srgb, var(--ok-ink) 30%, transparent); }
.toast-info { background: linear-gradient(var(--sky-soft), var(--sky-soft)) var(--card);     color: var(--navy); border-color: var(--sky-light); }
.toast-warn { background: linear-gradient(var(--warn-light), var(--warn-light)) var(--card); color: var(--warn-ink); border-color: color-mix(in srgb, var(--warn-ink) 30%, transparent); }
.toast-bad  { background: linear-gradient(var(--bad-light), var(--bad-light)) var(--card);   color: var(--bad-ink); border-color: color-mix(in srgb, var(--bad-ink) 30%, transparent); }
.toast-msg  { flex: 1; overflow-wrap: anywhere; }
.toast-close {
    border: none;
    background: transparent;
    cursor: pointer;
    font-size:12px;   /* ✕ glyph */
    color: inherit;
    opacity: 0.7;
    padding: 2px 4px;
}
.toast-close:hover { opacity: 1; }

/* --- Clickable rows / tiles ------------------------------------------- */
.row-link { cursor: pointer; }
.tbl tbody tr.row-link:hover > td { background: var(--sky-soft); }
/* A whole card that happens to be an <a> reads as a card, not as a link. */
.card-link, .card-link:visited { color: var(--navy); }
.card-link { transition: box-shadow 0.12s, border-color 0.12s; }
.card-link:hover { box-shadow: var(--shadow-md); border-color: var(--line-mid); }

/* T18 / TR-005: the row's IDENTITY CELL carries a real control (an <a> when the
   row navigates, a <button class="link-btn"> when it opens an inline editor), so
   a keyboard user can open the record even though the <tr @onclick> itself is
   mouse-only. The whole-row click stays for mouse users; this is the parity, not
   a replacement. A focused control also lights its row via :focus-within. */
.cell-link {
    background: none; border: none; padding: 0; margin: 0;
    font: inherit; cursor: pointer; text-align: left;
    color: inherit; text-decoration: none;
    font-weight: 600;
}
/* :visited twin — (0,2,0) beats `a, a:visited` (0,1,1). See VisitedCascadeTests. */
.cell-link, .cell-link:visited { color: inherit; }
.cell-link:hover { text-decoration: underline; }
.tbl tbody tr:focus-within > td { background: var(--sky-soft); }

/* T18 / TR-020: a sortable column header is a real <button> INSIDE the
   th.th-sort (the th carries aria-sort; the button carries the click).
   Full-bleed so the whole header stays the click target, inheriting the th's
   mono micro-cap styling so the tables render exactly as before. ⚠ th.th-sort
   keeps its own cursor/hover rules (app.css ~1609) — do not remove them; the
   hover tint is on the th, the activation is on this button. */
.th-sortbtn {
    background: none; border: none; cursor: pointer;
    font: inherit; color: inherit; letter-spacing: inherit;
    text-transform: inherit; text-align: inherit;
    padding: 0; margin: 0; width: 100%; display: inline-block;
}
/* Numeric columns right-align their header text; the full-bleed button must
   follow or every .num header silently flips left. */
.tbl th.num .th-sortbtn { text-align: right; }

/* T18 / TR-137: skip link — visually absent until it has keyboard focus, then a
   real button-shaped link in the top-left corner above everything. */
.skip-link {
    position: absolute; left: -9999px; top: 0; z-index: 400;
    background: var(--card); color: var(--ink);
    border: 1px solid var(--line-mid); border-radius: var(--r-sm);
    padding: 8px 14px; font-weight: 600; text-decoration: none;
    /* Measured 42px on padding alone. This is the FIRST target a keyboard or
       switch user reaches on every page, so it is the last one that should be
       two pixels short. */
    min-height: 44px; display: inline-flex; align-items: center;
}
/* :visited twin — (0,2,0) beats `a, a:visited` (0,1,1) in both themes and in any
   source order. Mandatory for any colour rule that can land on an <a>; pinned by
   VisitedCascadeTests, which sweeps the whole sheet rather than an allowlist. */
.skip-link, .skip-link:visited { color: var(--ink); }
.skip-link:focus-visible { left: 12px; top: 10px; }

/* Visually hidden but present for assistive tech and for <FocusOnNavigate
   Selector="h1">, which needs a real h1 on every route. */
.sr-only {
    position: absolute;
    width: 1px; height: 1px;
    padding: 0; margin: -1px;
    overflow: hidden;
    clip: rect(0 0 0 0);
    white-space: nowrap;
    border: 0;
}

/* --- Focus rings (accessibility + modern feel) ----------------------- */
/* ⚠ INVERTED 2026-07-29 (T18 / TR-017, TR-041). This used to be
   `:focus-visible { outline: none; }` plus an allowlist of nine selectors that
   got a ring back — and the allowlist missed ~239 of 691 buttons, every sortable
   header, every clickable row, the rail's group expanders, the account menu and
   the whole mobile bottom bar. An allowlist fails OPEN for accessibility: every
   element anyone forgets is silently invisible to a keyboard user, in production,
   with no error. The default is now a visible ring on EVERYTHING and specific
   rules may only ever restyle it — never remove it. Do not "tidy" this back. */
:focus-visible { outline: 2px solid var(--sky); outline-offset: 2px; }

/* T18 / TR-175: icon-only dismiss controls meet the 24x24 CSS-px minimum target
   size on DESKTOP too (the phone media query already raises them to 36px). */
.modal-close, .flash-close, .li-del, .toast-close {
    min-width: 24px; min-height: 24px;
    display: inline-flex; align-items: center; justify-content: center;
}

.btn:focus-visible,
.topbar-nav a:focus-visible,
.topbar-nav button:focus-visible,
a:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
.search-input:focus-visible,
.search-select-input:focus-visible,
.boards-card:focus-visible,
.boards-card-handle:focus-visible,
.boards-steptoggle:focus-visible,
.boards-cell-add:focus-visible {
    outline: 2px solid var(--sky);
    outline-offset: 2px;
    border-radius: var(--r-sm);
}

/* --- Dashboard -------------------------------------------------------- */
.dash {
    flex: 1;
    overflow-y: auto;
    padding: 24px 28px 30px;   /* mockup .content */
    /* The Blueprint canvas: white cards sit ON the warm stone — painting this
       paper-white flattened the whole page into white-on-white.
       background-COLOR, never the `background` shorthand: the shorthand resets
       background-image and wiped the drafting grid this element sets above,
       leaving Home the one page in the app with a blank canvas. */
    background-color: var(--bg);
}

/* ⚠ LEGACY dashboard styles: the old Home's card grid (.dash-grid*, .dash-card*,
   .dash-chart, .dash-hbar*, .dash-col*, .dash-donut*, .dash-seg) is no longer
   referenced by Home.razor — BUT .dash (the page wrapper) and .dash-hl* are
   STILL LIVE (Accounting.razor's overview uses .dash-hl rows). Prune carefully. */
.dash-grid {
    display: grid;
    grid-template-columns: repeat(3, minmax(0, 1fr));
    grid-auto-rows: 1fr;
    gap: 12px;
}
@media (max-width: 1080px) { .dash-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 680px)  { .dash-grid { grid-template-columns: 1fr; } }

.dash-grid2 {
    display: grid;
    grid-template-columns: repeat(4, minmax(0, 1fr));
    grid-auto-rows: 1fr;
    gap: 12px;
}
@media (max-width: 1080px) { .dash-grid2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 680px)  { .dash-grid2 { grid-template-columns: 1fr; } }

.dash-card {
    display: flex;
    flex-direction: column;
    gap: 9px;
    padding: 11px 14px;
}

/* Featured tile — Projects leads the board at double width. (No row span:
   its content is a short bar list, so a 2-row cell left a large void.) */
.dash-card-featured { grid-column: span 2; }
@media (max-width: 680px)  { .dash-card-featured { grid-column: span 1; } }

.dash-card-head {
    display: flex;
    align-items: center;
    gap: 9px;
    text-decoration: none;
    cursor: pointer;
    border-bottom: 1px solid var(--line);
    border-radius: var(--r-sm);
    margin: -4px -5px 0;
    padding: 4px 6px 8px;
}
.dash-card-head:hover { background: var(--sky-soft); }
.dash-card-icon  { font-size: 16px; line-height: 1; }
.dash-card-title { font-size: var(--fs-md); font-weight: 700; color: var(--navy); flex: 1; }
.dash-card-go    { font-size: var(--fs-2xs); font-weight: 600; color: var(--sky); text-transform: uppercase; letter-spacing: 0.05em; }
.dash-card-featured .dash-card-icon  { font-size: 22px; }
.dash-card-featured .dash-card-title { font-size: 17px; }

.dash-chart { flex: 1; display: flex; align-items: center; padding: 2px 4px; }

/* Horizontal bars (featured tile) */
.dash-hbars { width: 100%; display: flex; flex-direction: column; gap: 12px; padding: 2px; }
.dash-hbar-row { display: flex; align-items: center; gap: 12px; text-decoration: none; }
.dash-hbar-label { width: 110px; flex-shrink: 0; font-size: var(--fs-md); font-weight: 600; color: var(--navy); }
.dash-hbar-track { flex: 1; height: 20px; background: var(--fill-soft); border: 1px solid var(--line-soft); border-radius: 6px; overflow: hidden; }
.dash-hbar-fill  { height: 100%; min-width: 6px; border-radius: 6px; transition: opacity 0.12s; }
.dash-hbar-row:hover .dash-hbar-fill { opacity: 0.82; }
.dash-hbar-count { width: 32px; flex-shrink: 0; text-align: right; font-size: 16px; font-weight: 700; color: var(--navy); }

/* Vertical columns */
.dash-cols { width: 100%; display: flex; align-items: stretch; justify-content: space-around; gap: 12px; height: 96px; padding: 4px 6px 0; }
.dash-col { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 4px; text-decoration: none; }
.dash-col-val   { font-size: var(--fs-md); font-weight: 700; color: var(--navy); }
.dash-col-track { flex: 1; width: 60%; display: flex; align-items: flex-end; }
.dash-col-bar   { width: 100%; min-height: 4px; border-radius: 5px 5px 0 0; transition: opacity 0.12s; }
.dash-col:hover .dash-col-bar { opacity: 0.82; }
.dash-col-label { font-size: var(--fs-2xs); font-weight: 600; color: var(--navy-soft); text-align: center; line-height: 1.15; }

/* Donut + clickable legend */
.dash-donut-wrap { width: 100%; display: flex; align-items: center; gap: 12px; padding: 2px; }
.dash-donut { width: 84px; height: 84px; flex-shrink: 0; }
.dash-seg { cursor: pointer; stroke: var(--card); stroke-width: 1.5; transition: opacity 0.12s; }
.dash-seg:hover { opacity: 0.78; }
.dash-donut-total { font-size: 18px; font-weight: 700; fill: var(--navy); }
.dash-donut-legend { flex: 1; display: flex; flex-direction: column; gap: 2px; }

.dash-hl {
    display: flex;
    align-items: center;
    gap: 9px;
    text-decoration: none;
    padding: 5px 7px;
    border-radius: var(--r-sm);
    transition: background 0.1s;
}
.dash-hl:hover { background: var(--paper-warm); }
.dash-hl-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
.dash-hl-label-txt { flex: 1; font-size: var(--fs-base); color: var(--navy); }
.dash-hl-count { margin-left: auto; font-size: var(--fs-md); font-weight: 700; color: var(--navy); }

/* --- Settings page ---------------------------------------------------- */
.settings-wrap {
    max-width: 640px;
    display: flex;
    flex-direction: column;
    gap: 14px;
}
.settings-section { padding: 16px 18px; }
.settings-section h3 { margin-bottom: 4px; }
/* /settings jumps h1 -> h3. The headings are now h2 (correct outline depth);
   keep the h3 SIZE so nothing moves — depth and size are separate concerns. */
.settings-section > h2 { font-size: var(--fs-md); font-weight: 600; letter-spacing: -0.01em; margin-bottom: 4px; }
/* ⚠ The measure is the point. A settings page is prose plus short answers, and
   both were running the full width of a 1440px card — a ~190-character line to
   read, and a 1400px-wide box to type a UTC offset into. Field width is a
   SIGNAL: a box that wide claims it wants a paragraph. */
.settings-hint { font-size: var(--fs-sm); color: var(--navy-soft); margin-bottom: 12px; max-width: 72ch; }

/* Wraps the body of a settings panel so its form and prose share one measure.
   Not a card — nesting a .box inside a .box is the thing this avoids. */
.settings-col { max-width: 900px; }

/* --- Fold: a collapsible section (SharedUI/Fold.razor) ------------------
   The closed row has to read as a HEADER, not as a button — it is the page's
   table of contents when everything is shut. So it borrows .section-header's
   spec (mono micro-cap, hairline under) and adds a caret and a status slot. */
.fold { border-bottom: 1px solid var(--line); }
.fold:last-of-type { border-bottom: none; }
.fold-head {
    display: flex;
    align-items: center;
    gap: 10px;
    width: 100%;
    padding: 13px 2px;
    min-height: 44px;
    border: none;
    background: transparent;
    cursor: pointer;
    text-align: left;
    /* ⚠⚠ A TITLE, NOT A CAPTION — and it was a caption until 2026-08-05.
       These rows first borrowed .section-header: mono, 10.5px, uppercase, 0.16em
       tracking, --text-muted. That spec is built to LABEL a block of content
       sitting below it — small, quiet, deliberately subordinate to what it
       introduces — and it was the right choice while the form underneath was
       always visible.

       Once the sections fold, the row IS the content: the only thing on screen
       and the thing you click. Three penalties then compound on it — uppercase
       destroys the word shapes reading actually relies on, wide tracking pulls
       the letters apart, and monospace removes the proportional rhythm that
       makes a phrase scannable. Tolerable for a two-word label; "Ask for a
       review when a job finishes" at 10.5px became fine print to decode. And 14
       rows at one size and weight carry no hierarchy at all.

       So: the sans face, sentence case, ink. The ladder is now page title 21px
       → section title 15px → field label 11px, instead of two rungs sharing one. */
    font-family: var(--font-sans);
    font-size: 15px;
    font-weight: 600;
    letter-spacing: -0.005em;
    color: var(--navy);
}
.fold-head:hover .fold-title { text-decoration: underline; text-underline-offset: 3px; }
.fold-title { flex: 1; min-width: 0; }
/* Pushed to the caret's side of the title so a long title cannot squash it. */
.fold-head .pill { flex: none; letter-spacing: 0; text-transform: none; }

.fold-caret { display: flex; flex: none; color: var(--glyph-ui); }
.fold-caret svg { width: 16px; height: 16px; transition: transform 0.15s ease; }
.fold-open .fold-caret svg { transform: rotate(90deg); }
.fold-open .fold-caret { color: var(--brand); }

/* ⚠ display:none, not height/visibility. It keeps the collapsed content out of
   the TAB ORDER and out of the accessibility tree — a section you cannot see
   but can still tab into is worse than one that is simply open. */
.fold-body { display: none; padding: 2px 2px 20px; }
.fold-open > .fold-body { display: block; }

/* Inside a fold the section's own heading is the fold's header, so the stacked
   top margin that separates static sections would just be dead space. */
.fold-body > .settings-hint:first-child { margin-top: 0; }
.fold-body .section-header:first-child { margin-top: 0; }

@media (prefers-reduced-motion: reduce) {
    .fold-caret svg { transition: none; }
    /* ⚠ Here rather than in the rail's own reduced-motion block: that one is inside
       `@media (min-width: 1025px)`, and the rail's chevron turns at every width — the
       accordion works the same in the mobile overlay. */
    .navrail-caret svg { transition: none; }
}

/* For values whose length is known and short: an offset, a count, a code. */
.field-narrow input, .field-narrow select { max-width: 170px; }

/* Section rhythm inside a settings panel. More space ABOVE a header than below
   it, so the header belongs to what follows rather than floating between two
   groups — the six integrations here read as one endless form without it.
   ⚠ SCOPED to .settings-col deliberately. `.section-header` is used 94 times,
   including as a bare <span> in Teams.razor, and turning that into a flex
   container would promote an inline element to a block box on a page this
   change has nothing to do with. */
.settings-col .section-header {
    display: flex;
    align-items: baseline;
    gap: 10px;
    margin-top: 30px;
}
/* ⚠ NOT margin-top:0, which is what it was. In the single-column list this
   header is no longer the top of a card — it is a GROUP HEADING sitting directly
   under the previous panel's last row, and with no margin it butted against it
   and read as part of that row.
   ⚠ 34px matches the group-heading block near .company-sections. It has to be
   set HERE too: that block carries the same selector at the same specificity but
   appears EARLIER in the sheet, so this one wins on source order and a 30px here
   would silently undo it. */
.settings-col > .section-header:first-child { margin-top: 34px; }
/* The header carries the one thing you came to the page to find out: is this
   live, or is it still falling back to the shared platform account? */
.settings-col .section-header .pill { margin-left: auto; letter-spacing: 0; text-transform: none; }

/* --- Roles & Permissions --------------------------------------------- */
.perm-check {
    width: 16px;
    height: 16px;
    cursor: pointer;
    accent-color: var(--sky);
}
.perm-check:disabled { cursor: default; accent-color: var(--navy-soft); opacity: 0.8; }

/*  THE PERMISSION NAME STAYS PUT WHILE YOU SCROLL ACROSS THE ROLES.

    ⚠⚠ THE PHONE RULE ALREADY DID THIS AND ONLY ON A PHONE. The sticky
    first-column block lives inside `@media (max-width: 760px)`, so on a DESKTOP —
    where this matrix is actually edited — nothing was pinned on either axis. The
    2026-08-31 spec claimed the permission name was held in view; that was read
    off the selector without noticing the media query it sits in.

    Nine roles at 100px plus the label column overflows a laptop viewport, so you
    scroll right to reach Warehouse and lose the row's name on the way — which is
    one plausible route to ticking the wrong cell.

    ⚠ SCOPED TO .perm-matrix, NOT hoisted out of the media query. That block
    matches `.tbl-wrap:not(:has(.tbl-cards))` — every table in the product. Making
    all of them sticky on desktop to fix one is a change with no relationship to
    the problem, and the failure mode of a wrong one (a column painting over its
    neighbour) is silent.

    ⚠ HORIZONTAL ONLY, DELIBERATELY. A sticky HEADER ROW needs the scroll
    container to scroll vertically itself — `position: sticky` resolves against
    the nearest scrolling ancestor, and `.tbl-wrap` has `overflow-x: auto` with no
    height cap, so it never scrolls vertically and `top: 0` would resolve to
    nothing. Pinning the header means giving the wrap a `max-height`, which is
    what `.dispatch-scroll` does ("the board scrolls INSIDE this box"). That turns
    the matrix into a nested scroll region with the Employee Roles table stranded
    below it — a wheel trap for a problem the category filter already answers by
    removing rows. Left undone on purpose; see ROLES-PAGE-SPEC §4. */
.perm-matrix th:first-child,
.perm-matrix td:first-child {
    position: sticky;
    left: 0;
    background: var(--card);
    z-index: 1;
}
.perm-matrix thead th:first-child { z-index: 2; }

/*  ⚠⚠ THE CATEGORY ROW'S STICKY CELL MUST BE OPAQUE, AND `var(--fill)` IS NOT.
    Measured: it computes to `rgba(28, 25, 23, 0.07)` — SEVEN PER CENT. A sticky
    cell with a 93%-transparent background does not hide what slides beneath it,
    so scrolling sideways paints the category checkboxes straight THROUGH the
    word "Operations". Every other first cell was fine because the rule above
    gives it `var(--card)`, which is opaque; only the tinted category row
    overrode it.

    ⚠ This is NOT a new fault — the phone rule below has carried the same
    translucent fill since sticky columns were added, so it has been happening on
    every narrow screen. Extending sticky to the desktop simply made it visible
    at every width, which is how it finally got reported.

    The fix is the pattern `.pill-warn` already uses in this file: an OPAQUE
    colour underneath, with the translucent tint painted over it as a gradient
    image. Same pixels as before, nothing to see through. */
.perm-matrix .perm-group-row td:first-child {
    background-color: var(--card);
    background-image: linear-gradient(var(--fill), var(--fill));
}

/* The category name in the permissions matrix, which filters the grid to itself.
   Inherits the cell's own type so it reads as the label it replaced rather than
   as a control bolted on — the affordance is the hover and the pressed state,
   not a button chrome that would put nine boxes down the first column. */
.perm-group-btn {
    display: flex;
    align-items: center;
    gap: 6px;
    width: 100%;
    padding: 5px 8px;
    border: 0;
    background: none;
    font: inherit;
    color: inherit;
    text-align: left;
    cursor: pointer;
}
.perm-group-btn:hover { background: var(--fill-soft); }
/* aria-pressed is the state, so it is what the styling keys off — the two cannot
   drift apart the way a separate `.is-active` class can. */
.perm-group-btn[aria-pressed="true"] { background: var(--sky-light); color: var(--navy); font-weight: 700; }
.perm-group-btn-x { margin-left: auto; opacity: 0.7; font-size: var(--fs-2xs); }

/*  --- The matrix's own width -------------------------------------------------

    ⚠⚠ THE SIZING GOES ON THE TABLE, NEVER ON THE WRAP — 2026-08-15. Putting
    `width: max-content` on .tbl-wrap sizes the SCROLL CONTAINER to its own content
    and so defeats its own `overflow-x: auto`: measured at 375px that pushed the
    whole document 827px sideways, the worst horizontal overflow in the product.
    The wrap stays viewport-bounded and scrolls; the table takes max-content so the
    columns keep their natural width, with min-width:100% so a narrow matrix still
    fills the card. It is also what makes the sticky first-column rule do anything
    at all — that needs a container which actually scrolls.

    ⚠ Here rather than in an inline style on the <table> so the rule is one thing in
    one place. An inline width would also be unreachable by any stylesheet fix, at
    any specificity. */
.perm-matrix { width: max-content; min-width: 100%; }
.perm-matrix th.perm-role-col { width: 100px; text-align: center; }

/*  --- "There is more of this table" -------------------------------------------

    ⚠⚠ THE STATE IS WRITTEN BY table-scroll.js, WHICH MEASURES. Nothing here may be
    keyed on a viewport width or a role count: the matrix is one label column plus
    100px per role and the roster is per tenant, so three roles fit a phone, nine
    overflow a laptop and forty overflow anything. A `max-width: 760px` version of
    this would be wrong in both directions at once — and would look right on
    whichever screen it was written on.

        [data-scroll="none"]   it fits
        [data-scroll="start"]  at the left edge   — more to the RIGHT
        [data-scroll="both"]   in between         — more BOTH ways
        [data-scroll="end"]    at the right edge  — more to the LEFT

    ⚠⚠ AN INDICATOR, NOT A CONTROL. A "Fit to screen" mode lived here briefly and
    was removed: fitting means dividing the screen between the columns, so role
    names had to shorten to initials, which reads badly at nine roles and cannot
    work at all at forty — a column would end up narrower than its own checkbox.
    Scrolling is the honest answer at every roster size, so the whole treatment is
    a directional shadow, an arrow and a count.

    ⚠ SCOPED TO .perm-matrix-host, and that is what settles the specificity. The
    phone block further down gives EVERY .tbl-wrap an unconditional right-edge
    shadow at (0,2,0); these are (0,3,0) and win wherever they sit in the file, so
    this does not depend on source order and cannot be broken by moving the block.
    Verified by re-declaring that rule at the END of the document rather than by
    counting selector weights.

    ⚠ And that unconditional shadow is deliberately still the fallback. If the
    module never loads there is no data-scroll attribute, none of these match, and a
    phone gets exactly what /roles gave it before — a hint that is always on. The
    enhancement can fail without taking anything away. */
.perm-matrix-host .tbl-wrap[data-scroll="none"] { box-shadow: none; }
.perm-matrix-host .tbl-wrap[data-scroll="start"] { box-shadow: inset -10px 0 10px -10px rgba(0, 0, 0, 0.22); }
.perm-matrix-host .tbl-wrap[data-scroll="end"] { box-shadow: inset 10px 0 10px -10px rgba(0, 0, 0, 0.22); }
.perm-matrix-host .tbl-wrap[data-scroll="both"] {
    box-shadow: inset 10px 0 10px -10px rgba(0, 0, 0, 0.22),
                inset -10px 0 10px -10px rgba(0, 0, 0, 0.22);
}

/* The wrap becomes a tab stop only while it scrolls (table-scroll.js adds and
   removes tabindex with role="region"), so it needs a focus ring like anything
   else that can hold focus. */
.perm-matrix-host .tbl-wrap:focus-visible { outline: 2px solid var(--sky); outline-offset: 2px; }

/*  --- The rail: a second scrollbar, above the table --------------------------

    The native horizontal bar sits at the BOTTOM of ~70 permission rows, so you
    have to scroll the page past the whole grid to find out the grid scrolls. This
    is the same control where you start reading.

    ⚠ Hidden until the wrap has been MEASURED as overflowing — `display: none`
    rather than visibility or opacity, so it is not a silent tab-order or
    screen-reader passenger on a table that fits. Its inset and inner width are
    set by table-scroll.js, which is the only thing that can know them.

    ⚠⚠ `overflow-x: scroll`, NOT `auto`. An empty strip whose only content is a
    scrollbar is exactly what an overlay-scrollbar platform (macOS, iOS, and
    Windows with "automatically hide scroll bars" on) fades out when idle — which
    would leave a blank 10px gap above the table and no affordance at all, on the
    machines least likely to be the one this was styled on. `scroll` keeps the
    track present; the explicit sizes below keep it visible rather than hairline.

    ⚠ The height is set on the element AND the bar, because a strip shorter than
    its own scrollbar clips it. */
.perm-scroll-rail { display: none; }
.perm-matrix-host:has(> .tbl-wrap[data-scroll]:not([data-scroll="none"])) .perm-scroll-rail {
    display: block;
    height: 17px;
    margin-bottom: 6px;
    overflow-x: scroll;
    overflow-y: hidden;
    /* Firefox: `thin` alone renders it nearly invisible on a bare strip. */
    scrollbar-width: auto;
    scrollbar-color: var(--line-mid) var(--fill);
    border-radius: 5px;
    background: var(--fill);
}
.perm-scroll-rail > div { height: 1px; }
/*  ⚠ Overrides the sheet-wide 5px ::-webkit-scrollbar further up. That size is
    right for a bar riding alongside content it can be read against; here the bar
    IS the content, and 5px of it reads as a hairline rule rather than a control. */
.perm-scroll-rail::-webkit-scrollbar { height: 10px; }
.perm-scroll-rail::-webkit-scrollbar-track { background: var(--fill); border-radius: 5px; }
.perm-scroll-rail::-webkit-scrollbar-thumb { background: var(--line-mid); border-radius: 5px; }
.perm-scroll-rail::-webkit-scrollbar-thumb:hover { background: var(--ink3); }

/*  --- The pinned column's edge ------------------------------------------------

    A solid rule down the right of the Permission column, so the boundary between
    what is pinned and what moves is stated rather than inferred from the motion.

    ⚠⚠ AN INSET BOX-SHADOW, NOT `border-right`. table.tbl is `border-collapse:
    collapse`, and a collapsed border belongs to the TABLE's border grid rather
    than to the cell that declared it — so it does not travel with a
    `position: sticky` cell, and the rule would sit at the column's original
    position while the column itself stays pinned. A box-shadow paints inside the
    cell's own box and moves with it. Same pixel, and it cannot come adrift. */
.perm-matrix th:first-child,
.perm-matrix td:first-child { box-shadow: inset -1px 0 0 var(--line-mid); }


.role-chip {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    padding: 4px 8px;
    border: 1px solid var(--line);
    border-radius: var(--r-pill);
    background: var(--card);
    font-size: var(--fs-sm);
    font-weight: 600;
    color: var(--navy);
}
.role-chip-x {
    border: none;
    background: transparent;
    color: var(--bad);
    cursor: pointer;
    font-size:11px;   /* ✕ glyph */
    line-height: 1;
    padding: 0 2px;
}
.role-chip-x:hover { color: #fff; background: var(--bad); border-radius: 50%; }

/* === RESPONSIVE ====================================================== */
/* The base layout is a fixed-viewport desktop shell (100vh, overflow:hidden,
   inner scroll panes). The rules below progressively adapt it for narrow
   windows, tablets, and phones. */

/* --- Tablet: relax dense multi-column grids --------------------------- */
@media (min-width: 761px) and (max-width: 1080px) {
    /* Inline 3- and 4-up card grids step down to two columns. */
    [style*="repeat(4"],
    [style*="repeat(3"] { grid-template-columns: repeat(2, 1fr) !important; }
}

/* Below desktop (tablet AND phone): on an action bar that has a search field, the
   trailing Add/Export buttons ride UP onto the title row (right-aligned) and the
   search bar takes its own full-width row beneath — the buttons never stack below
   the search. */
@media (max-width: 1080px) {
    .action-bar:has(> .search-input-wrap) {
        flex-wrap: wrap;
        height: auto;
        min-height: var(--actionbar-h);
        row-gap: 6px;
    }
    /* Keep the spacer a horizontal filler so it pushes the buttons to the right of
       the title row. The phone base rule otherwise makes it a full-width line break,
       which is what was dropping the buttons below the search. */
    .action-bar:has(> .search-input-wrap) > .action-bar-spacer {
        order: 0;
        flex: 1 1 0 !important;
        flex-basis: 0 !important;
        height: auto !important;
    }
    /* Title first, then the spacer + buttons on the same row. */
    .action-bar:has(> .search-input-wrap) > .action-bar-title { order: 0; }
    .action-bar:has(> .search-input-wrap) > .btn { order: 1; }
    /* Search drops to its own full-width row underneath. */
    .action-bar:has(> .search-input-wrap) > .search-input-wrap {
        order: 2;
        flex: 1 1 100%;
        max-width: none !important;
        margin-right: 0 !important;
        margin-bottom: 5px !important;
    }
}

/* --- Top nav collapses into a hamburger dropdown --------------------- */
/* With the grouped two-level menu (only 5 top-level items) the horizontal bar is
   compact, so it stays visible down to a much smaller width than the old flat list
   needed. */
@media (max-width: 900px) {
    .topbar { position: relative; gap: 10px; }
    .topbar-date { display: none; }

    /* The search field stays a field — it just shrinks with the bar. The
       results panel leaves the field's anchor and spans the viewport width
       (anchored to the narrow pill it would overhang the screen edge). */
    .topbar-search { padding: 6px 12px; }
    .topbar-search-drop {
        position: fixed;
        top: calc(var(--topbar-h) + 4px);
        left: 12px;
        right: 12px;
        width: auto;
    }

    .topbar-nav {
        display: none;
        position: absolute;
        top: var(--topbar-h);
        left: 0;
        right: 0;
        flex: none;
        flex-direction: column;
        gap: 2px;
        padding: 8px;
        background: var(--card);
        border-bottom: 1px solid var(--line);
        box-shadow: var(--shadow-md);
        z-index: 250;
    }
    .topbar-nav.open { display: flex; }
    .topbar-nav a,
    .topbar-nav button {
        width: 100%;
        padding: 9px 12px;
        border-radius: var(--r-sm);
        text-align: left;
    }

    /* Groups stack in the panel: header on top, children shown indented below it
       (no hover — everything is expanded). */
    .nav-group { flex-direction: column; align-items: stretch; width: 100%; }
    .nav-caret { display: none; }
    .nav-dropdown {
        display: flex;
        position: static;
        min-width: 0;
        gap: 2px;
        margin: 0;
        padding: 0 0 4px 14px;
        border: none;
        border-radius: 0;
        box-shadow: none;
        background: transparent;
    }
}

/* --- Phones / small windows: switch to natural document scroll -------- */
@media (max-width: 760px) {
    /* Let the page scroll as a single document instead of fixed panes. */
    html, body { height: auto; overflow: visible; }
    body { min-height: 100vh; overflow-x: hidden; }

    .app-shell { height: auto; min-height: 100vh; overflow: visible; }

    /* Keep the top bar in view while the document scrolls. */
    .topbar { position: sticky; top: 0; }

    .page-body { overflow: visible; }

    /* Unwind the fixed-height inner scroll regions (some set overflow inline). */
    .main-content  { overflow: visible !important; }
    .content-scroll { overflow: visible; }
    .dash          { overflow: visible; }

    /* Status strip: let it grow and scroll its stage pills horizontally. */
    .status-strip { height: auto; }
    .strip-stage-row { overflow-x: auto; padding-bottom: 2px; }
    .strip-stage { flex-shrink: 0; }

    /* The desktop 28px canvas gutters are 15% of a 360px screen — pull the
       page-edge rows back in line with the content below them. */
    .content-scroll,
    .dash { padding-left: 12px; padding-right: 12px; }
    .status-strip { padding-left: 12px; padding-right: 12px; }

    /* Action bar wraps instead of clipping its controls. */
    .action-bar {
        height: auto;
        min-height: var(--actionbar-h);
        flex-wrap: wrap;
        gap: 6px;
        padding: 6px 12px;
        margin-bottom: 10px;
    }

    /* The flex spacer would otherwise pin the buttons to the title's line and
       shove them off-screen. Make it a full-width break so the title takes the
       first line and the controls wrap onto the next, left-aligned. */
    /* Scoped to real action bars: spacers inside section headers (box title
       + button rows) must stay horizontal fillers, or the full-width break
       crushes the title into a sliver ("Estima/tes"). */
    .action-bar > .action-bar-spacer { flex-basis: 100%; height: 0; }
    .action-bar > .action-bar-spacer-nw { flex-basis: unset !important; height: 0; }

    /* If a section header's title + button genuinely don't fit, the button
       wraps below the title instead of squeezing it. */
    .section-header { flex-wrap: wrap; row-gap: 6px; }

    /* Add/edit pages nest a Save/Cancel action bar inside the breadcrumb. Let
       the breadcrumb wrap and collapse that nested spacer (a 100% break here
       would blow the bar past the screen and clip "Save"); the buttons then
       sit together, right-aligned, on the breadcrumb row or just below it. */
    .strip-breadcrumb { flex-wrap: wrap; row-gap: 6px; }
    .strip-breadcrumb .action-bar {
        flex-basis: 100%;       /* drop onto its own full-width row */
        margin-left: 0;         /* stop pinning to the right edge (it overflowed) */
        padding: 0;
        height: auto;
        min-height: 0;
    }
    .strip-breadcrumb .action-bar .action-bar-spacer { display: none; }

    /* Collapse inline multi-column content grids to a single column. */
    [style*="grid-template-columns"] { grid-template-columns: 1fr !important; }

    /* Kanban flows naturally rather than relying on a fixed parent height,
       and stacks its stage columns vertically instead of scrolling sideways. */
    .kanban-board { height: auto; flex: none; flex-direction: column; overflow-x: visible; }
    .kanban-col { width: 100%; }
    .kanban-cards { overflow: visible; }

    /* (The old .tbl-keep-N column-hiding rules are gone — every list table
       now card-collapses via .tbl-cards instead of losing columns.) */

    /* Touch targets: 44px is the FLOOR on a phone, not a target to approach.
       ⚠ This block used to sit at 40/36px, which reads as "close enough" and
       is not — measured 2026-08-05 at 375px, 33 of 58 interactive elements on
       the dashboard were under 44. The stated user is one-handed, in
       sunlight, sometimes in gloves. Pinned by TouchTargetTests, which sweeps
       every min-height inside a phone media query rather than an allowlist. */
    .btn { min-height: 44px; padding: 11px 14px; }
    /* ⚠ .btn-sm is 24px in the base sheet and appears in 17 pages. It had NO
       phone rule at all, so `.btn { min-height }` above never reached it —
       the modifier won on source order and shipped a 24px tap target. */
    .btn-sm { min-height: 44px; padding: 10px 12px; }
    .view-toggle .btn { min-height: 0; padding: 5px 12px; }
    .modal-close, .flash-close, .li-del { min-width: 44px; min-height: 44px; }
    /* The close button is absolutely positioned, so its 44px hit area does not
       push the text — the reserve has to grow to match it by hand or a long
       message runs underneath the ✕. */
    .flash:has(.flash-close) { padding-right: 52px; }
    /* Top-bar icon buttons keep their 34px VISUAL box on desktop (mockup
       .iconbtn); on a phone they need a 44px hit area. min-*, not width/height,
       so the painted circle is unchanged. (R9-07) */
    .topbar-inbox .topbar-user-btn,
    .topbar-user-btn { min-width: 44px; min-height: 44px; }
    /* The home logo measured 26×26 and is on EVERY page — the smallest tap
       target in the product sat in the corner a thumb reaches for first. The
       painted logo keeps its size; only the hit box grows. */
    .topbar-logo-m { min-width: 44px; min-height: 44px; justify-content: center; }

    /* Filter chips are the primary affordance on every list page and measured
       27px tall. Unlike the icon buttons above there is no separate painted
       box to preserve — the pill IS the target, so it grows. */
    .bp-chip { min-height: 44px; display: inline-flex; align-items: center; padding: 5px 14px; }
    .bp-chips { gap: 8px; }

    /* ⚠ .cell-link IS THE CONTROL THAT OPENS A RECORD, and it measured 23px tall
       at 375px on ~100 instances across the list pages (2026-08-15 sweep). It is
       the T18/TR-005 keyboard-parity control in the identity cell — the one thing
       on a list row that a person is actually aiming for — and it was the smallest
       target on the page. It inherits the row's font and had no phone rule at all:
       the same "a modifier opts out of a floor by not being mentioned" shape that
       .btn-sm had above.
       ⚠ inline-flex, not block: the identity cell often carries a badge or a
       second line beside the link, and block would push those onto a new row. */
    .cell-link { min-height: 44px; display: inline-flex; align-items: center; }
    /* ⚠ The whole rail, not just its group headers. On a phone the rail IS the
       navigation — it is what the bottom bar's "More" opens — so every row in
       it is a primary tap target. Measured before this rule: group headers
       33px, .navrail-top 36px, children 31px, which is 19 of the 58 tappable
       things on the dashboard failing in one component. */
    .navrail-group,
    .navrail a,
    .navrail-top,
    .navrail .navrail-children a { min-height: 44px; }

    /* The dashboard's "Around the shop" rows are links, stacked and adjacent —
       at 36px each, a miss lands on the neighbouring one. ⚠ These were invisible
       to the first measurement pass because that panel was still loading. A
       target sweep has to run against a SETTLED page, or it reports the subset
       that happened to have rendered. */
    .bp-mini, .bp-xrow { min-height: 44px; }

    /* 16px inputs also stop iOS Safari's zoom-on-focus. */
    .field input, .field select, .field textarea,
    .search-select-input, .search-input,
    .li-row select, .li-row input, .ol-row input, .je-row input,
    /* ⚠ .cell-input was MISSING from this list until 2026-08-07. It lives in a
       <td> (and now in the detail head's rail), so it matched none of the four
       selectors above — meaning every inline qty/cost cell on the estimate page
       zoomed iOS on focus and offered a ~26px target. It is the one input in the
       app you are most likely to be tapping from a truck. */
    .cell-input, select.cell-input,
    /* The three standalone field/kiosk pages declare their own font sizes in
       page-level <style> blocks (Punch, QuickLog, PortalChat) — those files
       carry a matching @media override; these selectors are the shared half. */
    .clock-field select, .clock-field input,
    .ql-field select, .ql-field input, .ql-field textarea,
    .pc-input {
        font-size: 16px;
        min-height: 44px;
    }
    /* ⚠ Checkboxes and radios do NOT inherit the rule above (they are not in
       its selector list, and a font-size on them does nothing anyway). One on
       /company measured 13×40. The control paints at its native size; the
       label around it carries the rest of the target. */
    .field input[type="checkbox"],
    .field input[type="radio"] { min-width: 22px; min-height: 22px; }
    /* The wrapping <label> is the real target — clicking the words toggles the
       box — so it is the thing that has to clear 44, not the 22px control. */
    .field label:has(> input[type="checkbox"]),
    .field label:has(> input[type="radio"]) {
        min-height: 44px; display: flex; align-items: center; gap: 8px;
    }

    /* Wide tables scroll sideways inside .tbl-wrap; pin the identifying first
       column so the row you are reading keeps its name. Excluded for tables
       that already opt into phone-card mode — the two treatments fight. */
    .tbl-wrap:not(:has(.tbl-cards)) th:first-child,
    .tbl-wrap:not(:has(.tbl-cards)) td:first-child {
        position: sticky;
        left: -2px;
        background: var(--card);
        z-index: 1;
    }
    .tbl-wrap:not(:has(.tbl-cards)) thead th:first-child { z-index: 2; }
    /*  Tinted group rows keep their own fill rather than showing --card.
        ⚠ OPAQUE BASE + TINT, not `background: var(--fill)` — that computes to
        rgba(28,25,23,0.07) and a 93%-transparent sticky cell lets the checkboxes
        scroll visibly THROUGH the category name. See the .perm-matrix rule for
        the full note; this is the same fault and the same fix, and it was here
        first. */
    .tbl-wrap:not(:has(.tbl-cards)) .perm-group-row td:first-child {
        background-color: var(--card);
        background-image: linear-gradient(var(--fill), var(--fill));
    }
    /* Right-edge hint that there is more table off-screen. */
    .tbl-wrap:not(:has(.tbl-cards)) { box-shadow: inset -8px 0 8px -8px rgba(0, 0, 0, 0.18); }

    /* Toasts span the bottom of the screen and stay tappable. */
    .toast-stack { left: 12px; right: 12px; bottom: 12px; max-width: none; }
    .toast-close { min-width: 44px; min-height: 44px; }

    /* --- Card-collapse tables (.tbl-cards) ----------------------------
       Instead of silently hiding columns (.tbl-keep-N), each row becomes a
       DENSE card: the first labeled cell is the card title (bold, full
       width), the remaining fields pack into a two-column grid with their
       column name as a tiny label above the value. One row of label:value
       per field made every record a six-row tower — unscannable. */
    .tbl-cards thead { display: none; }
    .tbl-cards, .tbl-cards tbody { display: block; }

    /* The wrap dissolves so each record reads as its own bubble card. */
    .tbl-wrap:has(.tbl-cards) {
        border: none;
        border-radius: 0;
        background: transparent;
        overflow: visible;
    }

    .tbl-cards tbody tr {
        display: grid;
        grid-template-columns: 1fr 1fr;
        gap: 6px 16px;
        padding: 12px 14px;
        margin-bottom: 10px;
        border: 1px solid var(--line);
        border-radius: var(--r);
        background: var(--card);
        box-shadow: var(--shadow-soft);
    }
    .tbl-cards tbody tr:last-child { margin-bottom: 0; }
    /* Out-specify the desktop cell border so fields don't carry underlines. */
    table.tbl.tbl-cards tbody td,
    .tbl-cards tbody td {
        display: block;
        padding: 0;
        border-bottom: none;
        min-width: 0;
    }
    /* Must out-specify the td display:block rule above, or the label
       ::before renders inline and jams against the value. */
    table.tbl.tbl-cards tbody td[data-label],
    .tbl-cards tbody td[data-label] {
        display: flex;
        flex-direction: column;
        align-items: flex-start;   /* pills keep their size, don't stretch */
        gap: 2px;
    }
    .tbl-cards tbody td[data-label]::before {
        content: attr(data-label);
        font-size: var(--fs-2xs);
        font-weight: 600;
        text-transform: uppercase;
        letter-spacing: 0.04em;
        color: var(--navy-soft);
    }
    /* First labeled cell = the card title: full width, prominent, no label. */
    .tbl-cards tbody tr > td[data-label]:first-child {
        grid-column: 1 / -1;
        font-size: var(--fs-md);
        font-weight: 600;
    }
    .tbl-cards tbody tr > td[data-label]:first-child::before { display: none; }

    /* The Status pill rides the card's top-right corner beside the title, so
       state is visible at a glance; its label is redundant up there. */
    .tbl-cards tbody tr:has(> td[data-label="Status" i]) > td[data-label]:first-child {
        grid-column: 1;
    }
    table.tbl.tbl-cards tbody td[data-label="Status" i],
    .tbl-cards tbody td[data-label="Status" i] {
        grid-column: 2;
        grid-row: 1;
        align-items: flex-end;
        align-self: center;
    }
    table.tbl.tbl-cards tbody td[data-label="Status" i]::before,
    .tbl-cards tbody td[data-label="Status" i]::before { display: none; }
    /* Unlabeled cells (empty-state colspan, trailing action buttons) span. */
    .tbl-cards tbody td:not([data-label]) { grid-column: 1 / -1; }
    /* Numeric cells lose their column alignment inside a card. */
    .tbl-cards tbody td.num,
    table.tbl.tbl-cards tbody td.num { text-align: left; }

    /* --- Bottom-sheet modal variant (Sheet="true" on Modal) ----------- */
    .modal-overlay.modal-sheet { align-items: flex-end; padding: 24px 0 0; }
    .modal-sheet .modal-card {
        max-width: none !important;   /* beats the inline max-width */
        width: 100%;
        max-height: 94dvh;
        overflow-y: auto;
        border-radius: var(--r) var(--r) 0 0;
        border-bottom: none;
        animation: sheet-in 0.18s ease-out;
    }
}

@keyframes sheet-in {
    from { transform: translateY(24px); opacity: 0.6; }
    to   { transform: translateY(0);    opacity: 1; }
}

/* --- Touch ergonomics for coarse pointers (fingers, not mice) ---------- */
@media (pointer: coarse) {
    .user-menu-item      { min-height: 44px; }
    .nav-dropdown a      { min-height: 44px; display: inline-flex; align-items: center; }
    .sitesearch-hit      { min-height: 44px; }
    /* A picker's options are a vertical stack of adjacent targets — the case
       where an undersized row costs you the row above or below. */
    .search-select-option { min-height: 44px; display: flex; align-items: center; }
    .ledger-tab          { padding: 8px 12px; }
    /* Taller rows: most list rows are tap targets (row-link). */
    table.tbl tbody td   { padding-top: 10px; padding-bottom: 10px; }

    /* ⚠⚠ THE TABLET BAND — 761px to 1024px — GOT THE TOUCH UI WITH NONE OF THE
       TOUCH ERGONOMICS, AND NO TEST COULD SEE IT.
       The mobile chrome switches on at max-width:1024 (.mobilenav display:flex,
       and .navrail becomes a fixed off-canvas overlay), but every control floor
       lives in max-width:760. So an iPad in portrait — 768 (iPad 9), 810/820
       (iPad 10/Air), 834 (Pro 11"), 1024 (Pro 12.9") — rendered the finger-driven
       layout with mouse-sized targets. Measured at 768px: 3,281 of 4,002
       interactive elements under 44px, including a.navrail-group and
       a.navrail-top at 169x36 — the primary navigation, on a device with no
       pointer at all.
       ⚠ These are DUPLICATED here, not moved out of the 760px block. Moving them
       would redden TouchTargetTests.ThePhoneBlock_StillRaisesTheSharedButton,
       which is a positive control asserting the floors are inside a phone media
       query — and a deploy-blocking suite is not the place to be clever. A floor
       declared twice at the same value is inert; a floor declared nowhere is the
       bug this fixes.
       ⚠ pointer:coarse is the right axis because it describes the DEVICE, not the
       window: a 1200px tablet still has no mouse, and a 700px desktop window
       still does. */
    .btn                 { min-height: 44px; }
    .btn-sm              { min-height: 44px; }
    .view-toggle .btn    { min-height: 0; }        /* segmented control, not a tap row */
    .bp-chip             { min-height: 44px; display: inline-flex; align-items: center; }
    .cell-link           { min-height: 44px; display: inline-flex; align-items: center; }
    .modal-close, .flash-close, .li-del,
    .toast-close         { min-width: 44px; min-height: 44px; }
    .topbar-inbox .topbar-user-btn,
    .topbar-user-btn     { min-width: 44px; min-height: 44px; }
    .topbar-logo-m       { min-width: 44px; min-height: 44px; justify-content: center; }
    .navrail a,
    .navrail-top,
    .navrail-group,
    .navrail .navrail-children a { min-height: 44px; }
}

/* --- A list page leads with the list ---------------------------------------
   On the dashboard the KPI strip IS the content, so the hero treatment below
   is right. On a LIST page it is context in front of the thing you navigated
   for — and the phone layout stacks it, so it costs a screen. Measured on
   /projects at 375px: four stacked tiles plus a wrapped filter row put the
   first job card at y≈640, clipped by the bottom nav. Four screens of chrome
   to reach a list.

   The condition "a KPI strip immediately followed by a filter row" IS the
   definition of a list page, so this fixes every one of them at once rather
   than needing a modifier class on ~40 pages. :has() is already load-bearing
   in this sheet (.tbl-wrap:not(:has(.tbl-cards))), so it is not a new
   dependency. Specificity (0,2,0) beats the .kpi-box rules below whatever the
   source order.

   ⚠⚠ THIS WAS A HORIZONTAL SCROLLER UNTIL 2026-08-12 AND IT IS A WRAP NOW
   (Jared's call, having read one as broken on /accounting/bills). The scroller
   worked exactly as written — measured at 360px it held four 132px tiles,
   scrolled 208px, overflowed the page by nothing, and left 64px of the next
   tile showing — and READING as broken is the defect, whatever the numbers say.
   Its only affordance was that peek, which is 24px at 320px, and the scrollbar
   was hidden on purpose. A row you cannot tell is scrollable has silently
   hidden half its numbers.

   The height it was buying is bought a cheaper way instead: the tiles keep the
   compact padding and the 20px value, the caption sub-lines still go (a caption
   is what you cut when the list is the point), and the FIRST tile no longer
   goes full-width — the hero treatment below is for the dashboard, and on a
   list page it turned four tiles into three rows instead of two.

   ⚠⚠ A GRID, NOT A WRAPPED FLEX ROW, and the difference is the LAST ROW.
   `flex: 1 1 132px` wraps correctly but lets whatever lands on the final row
   GROW to fill it — measured at 560px, three tiles took the first row and the
   fourth sat alone at 540px, three times its neighbours, reading as a heading
   rather than a fourth number. `repeat(auto-fit, minmax(132px, 1fr))` fits the
   same count per row and keeps every tile one track wide, so an orphan is
   simply a tile. It also makes the ≤560px flex overrides below (the
   hero-first-tile `flex-basis: 100%`) inert without having to fight them: they
   are flex properties, and this is not a flex container. */
@media (max-width: 760px) {
    .kpi-strip:has(+ .bp-chips) {
        display: grid;
        grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
        gap: 8px;
        margin-bottom: 10px;
    }
    .kpi-strip:has(+ .bp-chips) .kpi-box {
        /* ⚠ min-width:0 — a grid item defaults to its min-content width, and a
           long money value would otherwise hold its track open and overflow. */
        min-width: 0;
        padding: 10px 12px;
    }
    .kpi-strip:has(+ .bp-chips) .kpi-val,
    .kpi-strip:has(+ .bp-chips) .kpi-box:first-child .kpi-val { font-size: 20px; }
    .kpi-strip:has(+ .bp-chips) .bp-tile-sub { display: none; }
}

/* --- Small phones: tighten spacing and stack the densest pieces ------- */
@media (max-width: 560px) {
    .content-scroll { padding: 10px; margin-left: auto; }

    /* Sheet 04 owner phone: the lead tile goes full-width and hero-sized
       (the mockup's "Available cash" mtile); the rest wrap 2-up. */
    .kpi-strip { flex-wrap: wrap; }
    .kpi-box { flex: 1 1 calc(50% - 5px); min-width: 120px; }
    .kpi-box:first-child { flex-basis: 100%; }
    .kpi-box:first-child .kpi-val { font-size: 26px; }

    /* Phone-size page titles (mockup: 19px on 390px screens). */
    .bp-greet h1, .bp-projhead-title h1 { font-size: 20px; }

    /* Two-column forms become single column. */
    .form-grid { grid-template-columns: 1fr; }

    /* Modals use the full width with snug margins. */
    .modal-overlay { padding: 16px 10px; }

    /* Search fields in action bars take their own full-width line so any
       trailing button wraps cleanly below instead of being crowded off the
       right edge. */
    .action-bar .search-input-wrap,
    .bp-projhead-actions .search-input-wrap { max-width: none !important; flex: 1 1 100%; }
}

/* --- Overflow hardening: nothing clips off-screen on phones ------------ */
/* On phones the page clips horizontal overflow (body { overflow-x: hidden }),
   so any content wider than the viewport would be hidden rather than
   scrollable. These rules make long, unbreakable values (emails, addresses,
   asset tags, config keys, URLs) wrap, and stop media/SVG charts from forcing
   the layout wider than their column. Wide data tables are exempt — they keep
   their own horizontal scroll inside .tbl-wrap. */
@media (max-width: 760px) {
    /* Flex items default to min-width:auto, so they refuse to shrink below
       their content's min-content width — a wide action bar or table would
       then push the whole content column past the viewport, where the page's
       overflow-x:hidden clips it. min-width:0 lets the column shrink to the
       screen so inner regions wrap (action bar) or scroll (.tbl-wrap) instead. */
    .page-body,
    .main-content,
    .content-scroll,
    .action-bar,
    .tbl-wrap { min-width: 0; }

    /* The .content-scroll:has(.tbl-wrap) rule earlier gives the scroll column
       width:auto + align-self:stretch so wide tables can scroll at mid widths.
       On a phone that is actively harmful: a table's min-content width climbs
       through .box/.acc-tile (min-width:auto) and stretches the column past
       its own parent — measured 519px inside a 385px .main-content — which
       drags body and html with it, and a mobile browser answers by scaling the
       whole page down. Pin the column to the screen and let .tbl-wrap do the
       horizontal scrolling it already knows how to do. */
    .content-scroll:has(.detail-tiles),
    .content-scroll:has(.tbl-wrap),
    .content-scroll:has(.kanban-board),
    .content-scroll:has(.boards-board) { width: 100%; max-width: 100%; margin-right: 0; }

    /* Cards must be allowed to be narrower than the widest thing inside them. */
    .acc-list,
    .acc-tile,
    .acc-body,
    .box { min-width: 0; }

    /* `anywhere` also LOWERS the box's min-content contribution, which lets a
       table be squeezed until money, account codes and doc numbers break
       mid-value. `break-word` still breaks a long unbreakable string but keeps
       the intrinsic width, so wide tables scroll inside .tbl-wrap instead. */
    .content-scroll,
    .box,
    .dash-card,
    .accordion-body,
    .kanban-card,
    .modal-body { overflow-wrap: break-word; }

    /* Belt and braces: a number or identifier must never break, whatever the
       container above says. */
    .num, .mono, .kpi-val { overflow-wrap: normal; white-space: nowrap; }

    /* Panels hand-roll their header rows as an inline-styled flex row
       (label · spacer · buttons) instead of using .action-bar, so the wrap
       rule above never reaches them. Unwrapped they are wider than a phone,
       and because a block box does not grow for them they spill out of the
       card, past the viewport, and force the whole document wider — at which
       point a mobile browser scales the entire page down to fit. Measured on
       the site-logs panel: a 469px row inside a 385px screen took the
       document to 519px.

       Scoped to the IDIOM, not the container. An earlier version keyed off
       .acc-body / .box / .accordion-body and missed the same row inside .row
       and .section-header (neither of which wraps: .row is display:flex with
       no flex-wrap, .section-header becomes flex via an inline style). An
       audit of every occurrence found two right-pushing idioms in this
       codebase — .action-bar-spacer (40 uses) and .ml-auto (24) — so both are
       matched here. flex-wrap is inert on a non-flex parent, so a .ml-auto
       used for plain block centring costs nothing. */
    /* ⚠ NO ELEMENT QUALIFIER — 2026-08-15. These were `div:has(> …)`, and the
       `div` was load-bearing in the wrong direction: when ProjectDetail's ten
       accordion headers became real <button>s (a keyboard fix — see .acc-head),
       every one of them carried a .action-bar-spacer and INSTANTLY stopped
       matching this rule, silently taking back the overflow fix the comment above
       measured at 469px-in-385px. `> ` already limits the match to the spacer's
       direct parent, which IS the idiom this rule says it is scoped to, so the
       element name was never doing anything except making the rule fragile. */
    :has(> .action-bar-spacer),
    :has(> .ml-auto) { flex-wrap: wrap; }

    code { overflow-wrap: anywhere; }

    /* Charts, images and any explicitly-sized media stay within their column. */
    img, svg, video, canvas { max-width: 100%; }
}

/* --- Very small phones (≈360px): reclaim every pixel of width ---------- */
@media (max-width: 400px) {
    /* Tighter page gutters give dense content the most room. */
    .content-scroll { padding: 8px; }
    .dash           { padding: 12px; }

    /* Keep the "viewing as" badge from crowding the top bar; truncate instead. */
    .viewing-as-badge {
        max-width: 116px;
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
    }
}

/* --- Authentication pages (login / forgot password) ------------------- */
/* ⚠ Column, not row. This was `align-items/justify-content: center` on a ROW,
   which centred the single card perfectly — and turned the brand footer added
   beside it into a second column that fell out of the viewport entirely
   (measured 559px below the fold). As a column the card still centres via its
   own `margin: auto` and the footer lands at the bottom where it belongs. */
.auth-shell {
    min-height: 100vh;
    display: flex;
    flex-direction: column;
    background: var(--bg, var(--paper-cool));
    padding: 24px;
}

.auth-card {
    width: 100%;
    max-width: 380px;
    padding: 28px 26px;
    margin: auto;   /* centres in whatever space the footer leaves */
}

/* Full-bleed across the shell's padding so it reads as a bar, not a stray line. */
.auth-shell > .brand-footer { margin: 0 -24px -24px; }

.auth-brand {
    display: flex;
    align-items: center;
    gap: 8px;
    text-decoration: none;
    margin-bottom: 4px;
}

/* Brand lockup. Shipped at 2x and constrained here, so it stays crisp on a
   retina screen. The wordmark is navy artwork, so the dark theme needs the
   white file instead — one of the two is always display:none, which also keeps
   screen readers from announcing the name twice. */
.brand-lockup {
    width: 260px;
    height: auto;
    max-width: 100%;
}

.brand-lockup-dark { display: none; }

[data-theme="dark"] .brand-lockup-light { display: none; }
[data-theme="dark"] .brand-lockup-dark { display: block; }

/* --- Vendor brand footer ------------------------------------------------
   Whose SOFTWARE this is, on every surface. Distinct from the TENANT's branding
   in the rail and the PDFs, which comes from dbo.Company and belongs to the
   customer — the two are deliberately never wired together. */
.brand-footer {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 6px;
    flex-wrap: wrap;
    flex: 0 0 auto;
    padding: 8px 14px;
    border-top: 1px solid var(--line-soft);
    background-color: var(--surface);
    color: var(--text-muted);
    font-size: var(--fs-2xs);
    line-height: 1.35;
}

.brand-footer-mark { width: 16px; height: 12px; flex: 0 0 auto; }
.brand-footer-name { font-weight: 700; color: var(--navy); letter-spacing: -0.01em; }
.brand-footer-dot  { opacity: 0.5; }

/* ⚠ REMOVED 2026-08-05, deliberately — do not reinstate without reading this.
   This used to be `.app-main > .brand-footer { padding-bottom: 78px+safe-area }`,
   scoped to the app shell because the floating .mobilenav draws OVER content
   below 1024px and the notice would sit behind it.

   That clearance now lives ONE level up, on .app-main / body, where it was
   raised to 148px to clear the Ask widget's FAB as well. The footer is inside
   that container, so it is cleared by construction — and keeping this rule made
   the two padding-bottoms STACK: measured ~100px of dead space between the
   footer text and the nav bar at 375px. One owner for the clearance, not two. */

/* Customer-facing attribution: no bar, no rule, just the line. The portal is the
   tenant's document surface and must not read as co-branded by us. */
.brand-footer-powered {
    border-top: 0;
    background: none;
    padding: 16px 12px 20px;
}

.auth-title { font-size: 16px; font-weight: 700; color: var(--navy); margin: 6px 0 2px; }
.auth-sub   { font-size: var(--fs-sm); color: var(--navy-soft); margin-bottom: 16px; }

.auth-form { display: flex; flex-direction: column; gap: 12px; }
.btn-block  { width: 100%; justify-content: center; padding: 0 12px; font-size: var(--fs-sm); }

.auth-divider {
    display: flex;
    align-items: center;
    gap: 10px;
    margin: 16px 0;
    color: var(--navy-soft);
    font-size: var(--fs-2xs);
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.06em;
}
.auth-divider::before,
.auth-divider::after { content: ""; flex: 1; height: 1px; background: var(--line); }

.auth-providers { display: flex; flex-direction: column; gap: 8px; }
.auth-provider-btn { width: 100%; justify-content: center; gap: 8px; }
.auth-provider-icon { font-weight: 700; }

.auth-foot { margin-top: 16px; text-align: center; font-size: var(--fs-sm); color: var(--navy-soft); }
.auth-foot a { color: var(--sky); text-decoration: none; font-weight: 600; }
.auth-foot a:hover { text-decoration: underline; }
.auth-foot-sep { margin: 0 8px; color: var(--line-mid); }

/* --- Circuit reconnect overlay (themed; see App.razor) ----------------
   Hidden by default; Blazor toggles the components-reconnect-* classes on
   #components-reconnect-modal as the SignalR circuit drops/retries. Using the
   theme variables keeps it readable in dark mode (the built-in overlay is a
   hardcoded-white shadow-DOM element app.css can't style). */
.reconnect-modal {
    display: none;
    position: fixed;
    inset: 0;
    z-index: 3000;
    align-items: center;
    justify-content: center;
    background: rgba(0, 0, 0, 0.5);
}
.reconnect-modal.components-reconnect-show,
.reconnect-modal.components-reconnect-failed,
.reconnect-modal.components-reconnect-rejected { display: flex; }

.reconnect-card {
    background: var(--card);
    color: var(--navy);
    border: 1px solid var(--line);
    border-radius: var(--r);
    box-shadow: var(--shadow-md);
    padding: 20px 24px;
    max-width: 340px;
    text-align: center;
}
.reconnect-title { font-size: var(--fs-lg); font-weight: 700; color: var(--navy); margin-bottom: 6px; }
.reconnect-msg { font-size: var(--fs-base); color: var(--navy-soft); }
.reconnect-msg a { color: var(--sky); font-weight: 600; text-decoration: none; }
.reconnect-msg a:hover { text-decoration: underline; }

/* Show only the message matching the current reconnect state. */
.reconnect-on-show, .reconnect-on-failed, .reconnect-on-rejected { display: none; }
.components-reconnect-show    .reconnect-on-show    { display: block; }
.components-reconnect-failed  .reconnect-on-failed  { display: block; }
.components-reconnect-rejected .reconnect-on-rejected { display: block; }

/* Recovery code grid (2FA enrollment) */
.recovery-grid {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 4px 12px;
}
.recovery-grid code {
    font-family: var(--font-mono);
    font-size: var(--fs-base);
    color: var(--navy);
    letter-spacing: 0.04em;
}

/* --- Estimator (Estimates page editor) ------------------------------- */
.field-hint { font-size: var(--fs-xs); font-weight: 400; color: var(--navy-soft); }

/* AI estimator prompt row */
.ai-row { display: flex; gap: 8px; }
.ai-row textarea { flex: 1; }
.ai-gen { align-self: flex-start; white-space: nowrap; }

/* The live customer price, in the estimator's page header beside Save.
   ⚠ THERE WAS A STICKY ACTION BAR HERE AND IT IS GONE ON PURPOSE (Andre, 2026-08-11:
   "it seems awkward how it is"). Pinned to the bottom of the scroll container it covered
   the line grid as you scrolled past it, and below 1024px it had to be switched to
   static anyway to keep out of the floating .mobilenav pill's 60px — a bar that has to
   stop behaving like a bar on most screens was not earning its place. The header already
   holds Save and Cancel; the number belongs next to them, not in a strip of its own.
   Not a .pill: this is a live figure the estimator reads while typing, and a badge shape
   would make it look like a status. */
.est-price {
    font-size: var(--fs-xs);
    color: var(--text-muted);
    white-space: nowrap;
    margin-right: 2px;
}
.est-price b { font-family: var(--font-mono); font-size: var(--fs-sm); color: var(--navy); font-variant-numeric: tabular-nums; }
@media (max-width: 560px) {
    /* The header actions wrap on a phone; the price leads its own line rather than
       squeezing Save and Cancel below the 44px touch floor. */
    .est-price { width: 100%; margin-right: 0; }
}

/* ⚠ A checkbox is not a text field. `.field input` sets width:100%, which stretches a
   checkbox's box across the whole cell and drops its label onto the next line — the
   estimator's "No-charge job" tick rendered adrift in the middle of the row, above its
   own words. Exactly what .li-owner resets for the line grid, needed again here.
   Scoped to the estimator rather than fixed on `.field` globally: the same shape is on
   every form-grid checkbox in the app and un-breaking them all at once is a wider change
   than this wave can look at. */
.est-form .field input[type="checkbox"] {
    width: auto;
    padding: 0;
    border: 0;
    background: transparent;
    margin-right: 6px;
    vertical-align: middle;
}

/* The estimator's form column (DiscoveryOne.CRM/Pages/EstimateEdit.razor).
   A ceiling, not a width: the line grid is already fluid, and what it lacked
   once it left the 880px dialog was an upper bound — the description track is
   1fr and would otherwise run the full width of a 2560px monitor, putting the
   description and its cost half a metre apart. */
.est-form { max-width: 1040px; }

/* Editable line-item grid */
.li-wrap { border: 1px solid var(--line); border-radius: var(--r-sm); overflow: hidden; background: var(--input-bg); }
/* Eight tracks: category, description, PHASE, qty, cost, ★opt, buys, ✕.
   The phase column (migration 159) arrived when the estimator stopped being an
   880px dialog and became a page — there was no room for it before, which is
   why a phase could only be set from EstimateDetail's row menu.
   ⚠ minmax(0,1fr), not a bare 1fr: a long description in a bare 1fr track can
   refuse to shrink below its own content and blow the whole row out sideways. */
.li-head, .li-row {
    display: grid;
    grid-template-columns: 104px minmax(0, 1fr) 128px 72px 104px 30px 30px 28px;
    gap: 6px;
    align-items: center;
    padding: 5px 8px;
}
.li-head {
    background: var(--fill);
    font-size: var(--fs-2xs);
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: var(--navy-soft);
    border-bottom: 1px solid var(--line);
}
.li-row { border-bottom: 1px solid var(--line-soft); }
.li-row:last-of-type { border-bottom: 0; }
.li-row select, .li-row input {
    width: 100%;
    padding: 4px 6px;
    font-size: var(--fs-sm);
    border: 1px solid var(--line);
    border-radius: var(--r-xs);
    background: var(--input-bg);
    color: var(--navy);
}
.li-row select:focus, .li-row input:focus { border-color: var(--sky); outline: none; box-shadow: 0 0 0 2px var(--sky-soft); }
.li-del {
    border: 0;
    background: transparent;
    color: var(--bad);
    font-size: 14px;
    cursor: pointer;
    line-height: 1;
    padding: 2px;
}
.li-del:hover { color: color-mix(in srgb, var(--bad) 82%, var(--ink)); }
/* Optional add-on toggle (☆/★): starred lines leave the totals and are
   offered to the client as checkboxes on the signing page. */
.li-opt {
    border: 0;
    background: transparent;
    color: var(--text-muted);
    font-size: 15px;
    cursor: pointer;
    line-height: 1;
    padding: 2px;
}
.li-opt.on { color: var(--warn-ink, #b07d2a); }
/* Phase of work per line (migration 159) — "Demo", "Cabinets", how a job is
   actually read. Free text with a <datalist> of the phases already on this
   estimate, deliberately NOT a select: there is no fixed list of phases, and
   inventing one is what would make the field useless. Sits quieter than the
   money columns beside it because it is a label, not a number.
   ⚠ `input.li-phase` for the LOOK, bare `.li-phase` for the HIDE — the header cell
   carries the class too, so the two vanish together on a phone. Styling the bare class
   would restyle the header out of line with its neighbours; hiding only the input left
   an 8-cell header over a 7-track grid and every column shifted one place. */
input.li-phase { font-size: var(--fs-xs); color: var(--navy-soft); }
/* Owner-supplied checkbox: reset the shared ".li-row input" rule above (full
   width, padded, bordered box) — a checkbox is not a text/number field. */
.li-owner {
    width: auto;
    height: auto;
    padding: 0;
    border: 0;
    background: transparent;
    justify-self: center;
    cursor: pointer;
}
.li-foot-opt { font-weight: 400; font-size: var(--fs-xs); color: var(--text-muted); border-top: 1px dashed var(--line); }
.li-add { padding: 6px 8px; border-top: 1px solid var(--line-soft); }
.li-add button {
    border: 1px dashed var(--line-mid);
    background: transparent;
    color: var(--sky);
    font-size: var(--fs-sm);
    font-weight: 600;
    border-radius: var(--r-xs);
    padding: 4px 10px;
    cursor: pointer;
}
.li-add button:hover { border-color: var(--sky); background: var(--sky-soft); }
.li-foot {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 7px 10px;
    background: var(--fill-soft);
    border-top: 1px solid var(--line);
    font-size: var(--fs-base);
    font-weight: 600;
    color: var(--navy);
}
.customer-price { font-weight: 700; background: var(--fill-soft) !important; }

/* ---- The INVOICE editor's line grid -----------------------------------------
   (DiscoveryOne.Accounting/Receivables/Pages/InvoiceEdit.razor)

   Deliberately its OWN tracks rather than a variant of .li-head/.li-row above. An
   invoice line and an estimate line are different documents: the estimate's eight
   tracks carry category, phase, an optional-add-on star and an owner-supplied tick,
   none of which exist here, and the invoice's revenue-account picker does not exist
   there. Sharing one template would mean every future column on either side had to
   be nulled out on the other.

   Two templates because the revenue-account picker is OFF by default — every line
   posts to 4000 and the bookkeeper who routes elsewhere turns the column on. The
   grid reflows rather than leaving a dead 180px gutter for a control most tenants
   never touch.

   ⚠ minmax(0,1fr) on the description, not a bare 1fr: a long line description in a
   bare 1fr track can refuse to shrink below its own content and blow the row out
   sideways. The dialog this replaced used flex with min-width, which wrapped
   instead — the columns stopped lining up as soon as one description was long. */
/* A document editor's form column: a CEILING, not a width. The grids inside are already
   fluid; what they lack once they leave a dialog is an upper bound, or a description and
   its cost end up half a metre apart on a 2560px monitor.
   ⚠ Eight names, one rule, on purpose. `.ord-form` (the purchase-order editor) joined
   `.inv-form` when the PO modals became pages; `.bill-form`, `.exp-form`, `.je-form`,
   `.co-form`, `.sl-form` and `.proj-form` followed as the payables, expense, journal,
   change-order, site-log and project dialogs went the same way. They are the same
   measurement, and listing them here is what stops the next editor adding another copy. */
.inv-form, .ord-form, .bill-form, .exp-form, .je-form, .co-form, .sl-form, .proj-form { max-width: 1040px; }
.inv-form > .box, .ord-form > .box, .bill-form > .box, .exp-form > .box,
.je-form > .box, .co-form > .box, .sl-form > .box, .proj-form > .box { margin-bottom: 12px; }

/* ---- The BILL editor's line grid (Payables/Pages/BillEdit.razor) -----------
   Account · Description · Job · Amount · ✕. Its own tracks rather than a variant of
   .inv-row or .ol-row: a bill line debits an ACCOUNT and carries a JOB, and has no
   quantity at all, so it shares no column with either.

   ⚠ minmax(0, …) on every flexible track. A grid item defaults to min-width:auto — its
   MIN-CONTENT — and a bare fr cannot force one smaller, so a long account name would hold
   the row open and .li-wrap's overflow:hidden would clip the rest.

   ⚠⚠ THE ✕ TRACK IS 44px BELOW 760px, NOT 28. `.modal-close, .flash-close, .li-del
   { min-width: 44px }` lives in `@media (max-width: 760px)` — the touch floor — and a
   narrower track cannot hold it. That mismatch is exactly what clipped .ol-row for as long
   as it existed; it is not being repeated here. */
.bl-head, .bl-row {
    display: grid;
    grid-template-columns: minmax(0, 1.4fr) minmax(0, 1.4fr) minmax(0, 1.2fr) 104px 28px;
    gap: 6px;
    align-items: center;
    padding: 5px 8px;
}
.bl-head {
    background: var(--fill);
    font-size: var(--fs-2xs);
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: var(--navy-soft);
    border-bottom: 1px solid var(--line);
}
.bl-row { border-bottom: 1px solid var(--line-soft); }
.bl-row:last-of-type { border-bottom: 0; }
.bl-row input {
    width: 100%;
    padding: 4px 6px;
    font-size: var(--fs-sm);
    border: 1px solid var(--line);
    border-radius: var(--r-xs);
    background: var(--input-bg);
    color: var(--navy);
}
.bl-row input:focus { border-color: var(--sky); outline: none; box-shadow: 0 0 0 2px var(--sky-soft); }
.bl-amt { text-align: right; font-variant-numeric: tabular-nums; }

@media (min-width: 561px) and (max-width: 760px) {
    /* The touch floor is on, so the ✕ column must fit it — see the warning above. */
    .bl-head, .bl-row { grid-template-columns: minmax(0, 1.4fr) minmax(0, 1.4fr) minmax(0, 1.2fr) 96px 44px; }
}

@media (max-width: 560px) {
    /* Below phone width the row stops being a table and becomes a card, as .inv-row and
       .ol-row both do: account, then description, then job and amount on one line with the
       ✕. The header goes with it — column captions over a card name nothing. */
    .bl-head { display: none; }
    .bl-row {
        display: flex;
        flex-wrap: wrap;
        gap: 6px;
        padding: 8px;
        align-items: center;
    }
    .bl-row .bl-acct, .bl-row .bl-desc { flex: 1 1 100%; }
    .bl-row .bl-job { flex: 1 1 140px; }
    .bl-row .bl-amt { flex: 1 1 96px; }
    /* ⚠ `flex: 0 0 auto`, never a fixed basis — the 44px touch minimum has to win. */
    .bl-row .li-del { flex: 0 0 auto; margin-left: auto; }
}

.inv-head, .inv-row {
    display: grid;
    grid-template-columns: minmax(0, 1fr) 84px 112px 104px 28px;
    gap: 6px;
    align-items: center;
    padding: 5px 8px;
}
.inv-head.with-acct, .inv-row.with-acct {
    grid-template-columns: 180px minmax(0, 1fr) 84px 112px 104px 28px;
}
.inv-head {
    background: var(--fill);
    font-size: var(--fs-2xs);
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: var(--navy-soft);
    border-bottom: 1px solid var(--line);
}
.inv-row { border-bottom: 1px solid var(--line-soft); }
.inv-row:last-of-type { border-bottom: 0; }
.inv-row input {
    width: 100%;
    padding: 4px 6px;
    font-size: var(--fs-sm);
    border: 1px solid var(--line);
    border-radius: var(--r-xs);
    background: var(--input-bg);
    color: var(--navy);
}
.inv-row input:focus { border-color: var(--sky); outline: none; box-shadow: 0 0 0 2px var(--sky-soft); }
.inv-amt { text-align: right; font-variant-numeric: tabular-nums; color: var(--navy); font-weight: 600; }

/* The billing-source tag, spanning the row under the line it belongs to. It is
   invisible data that decides whether a change order can be billed a second time,
   and a bookkeeper editing a draft is entitled to see which lines carry it. */
.inv-tag {
    grid-column: 1 / -1;
    font-size: var(--fs-2xs);
    color: var(--text-muted);
    padding-left: 2px;
}

@media (max-width: 760px) {
    /* Below the header's own width the grid stops being a table and becomes a card:
       description on its own line, then qty / price / amount / delete on the next.
       The header row goes with it — column captions over a card layout name nothing. */
    .inv-head { display: none; }
    .inv-row, .inv-row.with-acct {
        display: flex;
        flex-wrap: wrap;
        gap: 6px;
        padding: 8px;
        align-items: center;
    }
    .inv-row .inv-acct, .inv-row .inv-desc { flex: 1 1 100%; }
    .inv-row .inv-qty { flex: 0 0 76px; }
    .inv-row .inv-price { flex: 1 1 100px; }
    .inv-row .inv-amt { flex: 1 1 auto; }
    .inv-row .li-del { flex: 0 0 auto; }
    .inv-tag { flex: 1 1 100%; }
}

@media (max-width: 560px) {
    /* 16px is the iOS zoom floor: anything smaller makes Safari zoom the whole page on
       focus, and the row you were typing in walks off screen. 44px is the touch floor —
       TouchTargetTests fails any interactive rule inside a phone block that sets a smaller
       one. Same pair .cell-input and the estimator's grid already carry. */
    .inv-row input { font-size: 16px; min-height: 44px; }
}

/* Small inline spinner (AI generate button) */
.spinner {
    display: inline-block;
    width: 13px;
    height: 13px;
    margin-right: 6px;
    border: 2px solid rgba(255, 255, 255, 0.4);
    border-top-color: #fff;
    border-radius: 50%;
    animation: spin 0.7s linear infinite;
    vertical-align: -2px;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Ink spinner: the same spinner on a light surface (image thumbs / status text). */
.spinner-ink { border-color: rgba(31, 56, 100, 0.22); border-top-color: var(--navy); }

/* Category icon + legend — used to compact the line-item tables on phones. */
.cat-key { display: none; }
.cat-key-item { display: inline-flex; align-items: center; gap: 4px; font-size: var(--fs-xs); color: var(--navy-soft); text-transform: capitalize; }
.cat-ico { font-size:13px; line-height: 1; }   /* emoji glyph */
.cat-ico-cell { display: none; font-size: 16px; }          /* detail table cell: shown on mobile */

/* Editor: emoji category select + description button (both desktop-hidden). */
.cat-sel-mini { display: none; }
.desc-btn {
    display: none;
    border: 1px solid var(--line);
    background: var(--input-bg);
    border-radius: var(--r-xs);
    cursor: pointer;
    font-size: 14px;
    line-height: 1;
    padding: 3px 4px;
}
.desc-btn:hover { border-color: var(--sky); }

/* Mobile description popup (now rendered inside the shared Modal; only the
   body/footer helpers remain). */
.desc-pop-body { font-size: var(--fs-md); color: var(--navy); white-space: pre-wrap; word-break: break-word; }
.desc-pop-foot { display: flex; justify-content: flex-end; }
.desc-view-btn { vertical-align: middle; }

/* Estimator on small screens: stack the AI row; category becomes an icon (see
   the .cat-key legend); the editor's Description column moves to a popup so the
   fixed columns fit a ~360px phone without clipping. */
@media (max-width: 560px) {
    .ai-row { flex-direction: column; }
    .ai-row .ai-gen { align-self: stretch; width: 100%; justify-content: center; }

    /* Legend + detail-table icon swap; Description column collapses to the 📝 popup. */
    .cat-key { display: flex; flex-wrap: wrap; gap: 4px 12px; margin: 2px 0 6px; }
    .cat-ico-cell { display: inline; }
    .cat-text { display: none; }
    .col-desc { display: none; }

    /* Editor rows: icon category, description via popup, header row hidden. */
    .li-head { grid-template-columns: 54px 30px 1fr 1fr 22px 22px 24px; }
    /* ⚠ Both templates below stay at SEVEN tracks. The phase moves into the
       "Line details" popup the 📝 button opens, next to the description that is
       hidden here for the same reason — an eighth track on a 375px phone would
       leave every column too narrow to read, and phase is the one field you set
       once and rarely revisit. */
    .li-phase { display: none; }
    .cat-sel-full, .desc-inp { display: none; }
    .cat-sel-mini { display: block; }
    .desc-btn { display: inline-flex; align-items: center; justify-content: center; }
    .li-row {
        grid-template-columns: 52px 30px 1fr 1fr 22px 22px 24px;
        gap: 6px;
        padding: 6px;
    }
    .li-row .cat-sel-mini { padding: 4px 2px; text-align: center; }
}

/* Multi-value "Type" tags rendered as pills in table cells / detail rows. */
.type-pill { margin: 0 3px 2px 0; }

/* --- Customers table: responsive column cascade ----------------------
   Full screen shows Address in its own column. As the viewport narrows the
   table sheds columns (and Address falls back to a sub-line under the name)
   so it never clips: >1024 all columns; ≤1024 drop Address; ≤760 also drop
   Owner; ≤560 also drop Email. */
.addr-sub { display: none; }
@media (max-width: 1024px) {
    .cust-tbl .col-addr { display: none; }
    .cust-tbl .addr-sub { display: block; }
}
@media (max-width: 760px) {
    .cust-tbl .col-owner { display: none; }
}
@media (max-width: 560px) {
    .cust-tbl .col-email { display: none; }
}

/* --- Expenses: mobile receipt capture + expense cards ------------------- */
/* The capture hero — the primary field action on a phone. It's a <label>
   wrapping a hidden InputFile so a tap opens the camera / file picker. */
.receipt-capture {
    display: flex;
    align-items: center;
    gap: 12px;
    width: 100%;
    padding: 14px;
    margin-bottom: 12px;
    border: 1.5px dashed var(--sky);
    border-radius: var(--r);
    background: var(--sky-soft);
    cursor: pointer;
    transition: background 0.12s ease, border-color 0.12s ease;
}
.receipt-capture:hover { background: var(--sky-light); border-color: var(--accent-dn); }   /* hover deepens the redline, never grays it */
.receipt-capture-icon { font-size: 26px; line-height: 1; }
.receipt-capture-main { display: flex; flex-direction: column; gap: 2px; flex: 1; }
.receipt-capture-title { font-weight: 700; font-size: var(--fs-md); color: var(--navy); }
.receipt-capture-sub { font-size: var(--fs-sm); color: var(--navy-soft); }
.receipt-capture-cta {
    font-size: var(--fs-sm);
    font-weight: 700;
    color: #fff;
    background: var(--deep);   /* white-on-sky failed AA at this size */
    padding: 6px 12px;
    border-radius: var(--r-pill);
    white-space: nowrap;
}

/* Receipt attach row on the expense editor page (/expenses/new, /{id}/edit). It was
   inside a 560px modal until 2026-08-12. */
.receipt-row { display: flex; gap: 10px; align-items: flex-start; }

/* THE RECEIPT AT DOCUMENT SIZE — /expenses/{id}'s left column, and the editor's preview.
   ⚠ The box is RESERVED by aspect-ratio before the photo loads, never grown by it: this
   sits above the notes and beside the rail on the detail page, and above every control on
   the editor, so a picture that arrives late and sets its own height walks a button out
   from under a pointer already moving toward it. (Same rule ImageReservationTests pins for
   the site-log tiles.)
   ⚠ `contain`, not `cover`. Cropping a receipt to fill a box crops the total off it, and
   the total is the thing the approver is being asked to agree with. */
.receipt-doc {
    width: 100%;
    max-width: 420px;
    aspect-ratio: 3 / 4;              /* a till receipt is tall */
    border: 1px solid var(--line);
    border-radius: var(--r-sm);
    background: var(--fill);
    overflow: hidden;
    display: flex;
    align-items: center;
    justify-content: center;
}
/* ZoomImage wraps its <img> in a bare inline-block button, which would otherwise size
   itself to the photo and leave the reserved box to be filled by nothing. */
.receipt-doc .zoom-trigger { display: block; width: 100%; height: 100%; }
.receipt-doc img { width: 100%; height: 100%; object-fit: contain; }
.receipt-doc-empty { font-size: 40px; opacity: 0.45; }
/* The editor's copy: small enough to sit beside its own controls, same reserved shape. */
.receipt-doc-sm { flex: 0 0 168px; width: 168px; }
@media (max-width: 560px) {
    /* 168px + the gap + a "Replace photo" button does not fit a 360px phone, and the
       controls would be squeezed to their min-content instead of the photo shrinking. */
    .receipt-row { flex-direction: column; }
    .receipt-doc-sm { flex: 0 0 auto; width: 100%; max-width: 260px; }
}

.receipt-thumb {
    width: 76px;
    height: 76px;
    flex: 0 0 76px;
    border: 1px solid var(--line);
    border-radius: var(--r-sm);
    background: var(--fill);
    overflow: hidden;
    display: flex;
    align-items: center;
    justify-content: center;
}
.receipt-thumb img { width: 100%; height: 100%; object-fit: cover; }
.receipt-thumb-empty { font-size: 30px; opacity: 0.5; }

/* Expense cards — a mobile-friendly list (not a dense table). */
.expense-list { display: flex; flex-direction: column; gap: 8px; }
/* B8-09. The card list is virtualized, and <Virtualize> works by rendering two
   tall spacer divs around the visible window. In a flex column those spacers are
   flex items, and the default flex-shrink:1 lets the algorithm squash a
   5,000-row spacer down to nothing — which silently collapses the scroll range
   the virtualizer depends on. Pinning the children is what keeps the scrollbar
   honest. */
.expense-list > * { flex-shrink: 0; }
.expense-card {
    display: flex;
    gap: 10px;
    padding: 10px;
    border: 1px solid var(--line);
    border-radius: var(--r);
    background: var(--card);
    box-shadow: var(--shadow-soft);
    cursor: pointer;
    transition: border-color 0.12s ease, box-shadow 0.12s ease;
}
.expense-card:hover { border-color: var(--sky); box-shadow: var(--shadow-md); }
.expense-thumb {
    width: 52px;
    height: 52px;
    flex: 0 0 52px;
    border-radius: var(--r-sm);
    background: var(--fill);
    overflow: hidden;
    display: flex;
    align-items: center;
    justify-content: center;
}
.expense-thumb img { width: 100%; height: 100%; object-fit: cover; }
.expense-thumb-emoji { font-size: 24px; }
.expense-main { flex: 1; min-width: 0; }

/* --- Orders PO editor: line-item grid (reuses .li-wrap/.li-del/.li-add) --- */
.ol-head, .ol-row {
    display: grid;
    /* Description · Qty · Unit · Unit$ · Stock SKU · delete */
    grid-template-columns: 1fr 54px 54px 88px 0.9fr 26px;
    gap: 6px;
    align-items: center;
    padding: 5px 8px;
}
.ol-head {
    background: var(--fill);
    font-size: var(--fs-2xs);
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: var(--navy-soft);
    border-bottom: 1px solid var(--line);
}
.ol-row { border-bottom: 1px solid var(--line-soft); }
.ol-row:last-of-type { border-bottom: 0; }
.ol-row input {
    width: 100%;
    padding: 4px 6px;
    font-size: var(--fs-sm);
    border: 1px solid var(--line);
    border-radius: var(--r-xs);
    background: var(--input-bg);
    color: var(--navy);
}
.ol-row input:focus { border-color: var(--sky); outline: none; box-shadow: 0 0 0 2px var(--sky-soft); }

/* ⚠⚠ 561–760px: THE ✕ COLUMN MUST BE 44px HERE TOO, and this band is the half that is
   easy to miss. The touch floor lives in `@media (max-width: 760px)`, NOT 560 — so between
   561 and 760 the desktop six-track grid (26px for the ✕) meets a 44px button and the row
   overflows by 18px, clipped by .li-wrap's overflow:hidden exactly as it did on a phone.
   The card layout below is the right answer at phone width and the wrong one at 700px,
   where there is plenty of room for a table; widening the one track is all this band needs.
   ⚠ Measured, not reasoned: 561px gave rowScrollWidth 511 against clientWidth 501 before
   this rule existed. If you change the touch floor's breakpoint, change this one with it. */
@media (min-width: 561px) and (max-width: 760px) {
    .ol-head, .ol-row { grid-template-columns: 1fr 54px 54px 88px 0.9fr 44px; }
}

@media (max-width: 560px) {
    /* ⚠⚠ THIS WAS A SIX-TRACK GRID WITH A 20px COLUMN FOR THE ✕, AND THE ✕ IS 44px.
       The phone block above sets `.modal-close, .flash-close, .li-del { min-width: 44px }`
       — the touch floor, which TouchTargetTests enforces — and a grid item cannot shrink
       below its own min-content, so the delete button overflowed its track by 24px. The
       row's scrollWidth measured 341 against a 323 client width at 375px; `.li-wrap`'s
       `overflow: hidden` then CLIPPED it, so the grid ran to the edge of its box while the
       rest of the form sat inside the page gutter. The two rules were written years apart
       and neither is wrong alone.
       Widening the track was the small fix and it is not the right one: the remaining
       tracks already left the description 73px and the quantity 40px on a 375px screen,
       which is a form you cannot read what you typed into.

       So below 560px the row stops being a table and becomes a CARD, exactly as .inv-row
       does — description on its own line, the three numbers on the next, the stock SKU and
       the ✕ on the last. The header goes with it: column captions over a card name
       nothing. Same reasoning, same shape, deliberately. */
    .ol-head { display: none; }
    .ol-row {
        display: flex;
        flex-wrap: wrap;
        gap: 6px;
        padding: 8px;
        align-items: center;
    }
    .ol-row .ol-desc { flex: 1 1 100%; }
    .ol-row .ol-qty  { flex: 1 1 70px; }
    .ol-row .ol-unit { flex: 1 1 60px; }
    .ol-row .ol-cost { flex: 1 1 90px; }
    .ol-row .ol-sku  { flex: 1 1 140px; }
    /* ⚠ `flex: 0 0 auto`, never a fixed basis: the 44px minimum is the touch floor and it
       has to win. This is the item that started the whole defect. */
    .ol-row .li-del  { flex: 0 0 auto; margin-left: auto; }
}

/* --- Timeline (Gantt) --------------------------------------------------- */
/* Class-based grids so the ≤760 inline-grid flattening net can't collapse
   the day tracks (bars are placed by grid-column and vanish in a 1fr grid).
   The wrapper stays a .tbl-wrap, so the intact chart scrolls horizontally on
   phones like any wide table. Day count arrives via --gantt-days. */
.gantt-grid {
    display: grid;
    grid-template-columns: 220px 1fr;
    gap: 0;
    min-width: 720px;
}

.gantt-days {
    display: grid;
    grid-template-columns: repeat(var(--gantt-days, 42), 1fr);
    align-items: center;
}

/* --- General ledger: sub-nav tabs + journal-entry line grid ------------- */
/* Underline-style tab bar (the original app's tabs): active tab carries a
   2px bottom border that sits on the bar's own border line. */
.ledger-tabs {
    display: flex;
    gap: 2px;
    flex-wrap: wrap;
    align-items: center;
    margin-bottom: 14px;
    border-bottom: 1px solid var(--line);
}
.ledger-tab {
    font-size: var(--fs-sm);
    font-weight: 500;
    color: var(--navy-soft);
    text-decoration: none;
    padding: 8px 13px;
    border-bottom: 2px solid transparent;
    margin-bottom: -1px;
    white-space: nowrap;
    transition: color 0.12s, background 0.12s;
}
.ledger-tab:hover { background: var(--paper-warm); color: var(--navy); }
.ledger-tab.active { color: var(--navy); border-bottom-color: var(--navy); font-weight: 600; }

/* --- Change-order item grid (Description · Qty · Rate · Amount · remove) --------
   ⚠⚠ ITS OWN CLASS SINCE 2026-08-12, AND THE REASON IS MEASURED. The change-order editor
   reused the ESTIMATOR's `.li-head` / `.li-row` — an EIGHT-track template — with an inline
   four-track `grid-template-columns` override on the ROW only. So the header sat on the
   estimator's tracks and the row on its own, and at 1280px they lined up like this:

       header:  Description(104px)  Qty(294px)  Rate $(128px)  ·(72px)      → ends x=840
       row:     input(628px)        input(70px) input(90px)    ✕(26px)      → ends x=1056

   "Qty" and "Rate ($)" both sat above the DESCRIPTION field, and the actual Qty and Rate
   inputs (x=858 and x=934) had NO HEADER AT ALL — the header row had already ended. Three
   labelled columns, none of them over the field it named, on a document that prices signed
   scope. Measured in a browser, not reasoned about.

   ⚠ minmax(0, …) on the flexible track: a grid item defaults to min-width:auto — its
   MIN-CONTENT — and a bare fr cannot force one smaller, so a long description would hold
   the row open and .li-wrap's overflow:hidden would clip the rest. */
.co-head, .co-row {
    display: grid;
    grid-template-columns: minmax(0, 1fr) 76px 96px 96px 26px;
    gap: 6px;
    align-items: center;
    padding: 5px 8px;
}
.co-head {
    background: var(--fill);
    font-size: var(--fs-2xs);
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: var(--navy-soft);
    border-bottom: 1px solid var(--line);
}
.co-row { border-bottom: 1px solid var(--line-soft); }
.co-row:last-of-type { border-bottom: 0; }
.co-row input {
    width: 100%;
    padding: 4px 6px;
    font-size: var(--fs-sm);
    border: 1px solid var(--line);
    border-radius: var(--r-xs);
    background: var(--input-bg);
    color: var(--navy);
}
.co-row input:focus { border-color: var(--sky); outline: none; box-shadow: 0 0 0 2px var(--sky-soft); }
/* The rounded per-line figure (script 49) — read-only, so it reads as text, not a field. */
.co-amt { font-size: var(--fs-sm); font-variant-numeric: tabular-nums; color: var(--navy); text-align: right; }

/* ⚠⚠ THE ✕ TRACK BELOW THE TOUCH FLOOR. `.modal-close, .flash-close, .li-del
   { min-width: 44px }` lives in `@media (max-width: 760px)`, so any grid reserving less
   than 44px there overflows — and .li-wrap is overflow:hidden, so it CLIPS rather than
   scrolls and the delete button simply is not there. Same trap .ol-row shipped with and
   .je-row carried latent for as long as it existed. */
@media (min-width: 561px) and (max-width: 760px) {
    .co-head, .co-row { grid-template-columns: minmax(0, 1fr) 68px 84px 84px 44px; }
}

/* Phone: the header goes, and each item becomes a card — the description on its own line
   with the three figures beneath it, because a 4-column grid at 360px gives the
   description about 90px. */
@media (max-width: 560px) {
    .co-head { display: none; }
    .co-row {
        display: flex;
        flex-wrap: wrap;
        gap: 6px;
        padding: 8px;
        margin-bottom: 8px;
        border: 1px solid var(--line);
        border-radius: var(--r-sm);
        background: var(--card);
    }
    .co-row .co-desc { flex: 1 1 100%; }
    .co-row .co-qty { flex: 1 1 68px; }
    .co-row .co-rate { flex: 1 1 84px; }
    .co-row .co-amt { flex: 1 1 auto; align-self: center; }
    .co-row .li-del { flex: 0 0 auto; margin-left: auto; }
}

/* Change-order photographs on the record page. The box is RESERVED at 96x72 — the same
   size the <img> declares — so a thumbnail that decodes late cannot move the ✕ that sits
   on top of it or the buttons below the grid. */
.co-photos { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 6px; }
.co-photo { position: relative; width: 96px; height: 72px; }
.co-photo img {
    width: 96px;
    height: 72px;
    object-fit: cover;
    border-radius: var(--r-sm);
    border: 1px solid var(--line);
    display: block;
}
.co-photo-del { position: absolute; top: 2px; right: 2px; }

/* --- Site-log editor: the staged/attached photo strip -------------------------
   ⚠ THE BOX IS RESERVED AT 96x72, which is also what the <img> declares in its width and
   height ATTRIBUTES — the pair is the point. These thumbs sit ABOVE the AI summary, the
   client-visibility checkbox and the page's own hint text, so a photo that decodes late
   and sizes its own box walks all of that down the screen. It was inline styles on the
   dialog; a class is what lets the staged and stored tiles be provably the same size.
   (Same rule ImageReservationTests pins for `.slv-photo` and its skeleton.) */
.sl-thumbs { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 6px; align-items: flex-start; }
.sl-thumb { position: relative; width: 96px; }
.sl-thumb img {
    width: 96px;
    height: 72px;
    object-fit: cover;
    border-radius: var(--r-sm);
    border: 1px solid var(--line);
    display: block;
}
/* A dashed border is the whole visual difference between "in the database" and "in this
   page's memory, and gone if you navigate away". */
.sl-thumb-staged img { border-style: dashed; }
.sl-thumb-del { position: absolute; top: 2px; right: 2px; }
.sl-thumb-cap {
    font-size: 10px;
    line-height: 1.25;
    margin-top: 2px;
    color: var(--text-muted);
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
}

/* Balanced/unbalanced indicator for the JE editor. */
.je-balance-ok  { color: var(--ok-ink); font-weight: 700; }
.je-balance-bad { color: var(--bad-ink); font-weight: 700; }

/* Journal-entry line grid (Account · Memo · Debit · Credit · Job · remove). Each
   field sits in a .je-cell so a per-field label can be shown on mobile.

   ⚠ SIX TRACKS SINCE 2026-08-12, not five. The MEMO column arrived when the composer
   stopped being an 840px dialog and became a page — acct.JournalLines.Memo has always
   been stored and AccountantPack has always exported it as LineMemo, but nothing could
   SET it, so every line carried a copy of the entry's memo. There was no room for a
   third text field on a row that already had two pickers and two number boxes.

   ⚠ minmax(0, …) on every flexible track. A grid item defaults to min-width:auto — its
   MIN-CONTENT — and a bare fr cannot force one smaller, so a long account name would
   hold the row open and .li-wrap's overflow:hidden would clip the rest.

   ⚠⚠ AND THE ✕ TRACK WAS 26px ACROSS THE WHOLE TOUCH BAND, which is the .ol-row bug
   over again and it was latent here the entire time. `.modal-close, .flash-close,
   .li-del { min-width: 44px }` lives in `@media (max-width: 760px)`, while this row only
   stacks into a card at 560px — so from 561 to 760px a 44px button sat in a 26px track,
   overflowed, and .li-wrap CLIPPED it rather than scrolling. Silently: the delete button
   was simply not there. The block below is the fix. */
.je-head, .je-row {
    display: grid;
    grid-template-columns: minmax(0, 1.2fr) minmax(0, 1.1fr) 92px 92px minmax(0, 0.9fr) 26px;
    gap: 6px;
    align-items: center;
    padding: 5px 8px;
}
.je-head {
    background: var(--fill);
    font-size: var(--fs-2xs);
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: var(--navy-soft);
    border-bottom: 1px solid var(--line);
}
.je-row { border-bottom: 1px solid var(--line-soft); }
.je-row:last-of-type { border-bottom: 0; }
.je-cell { min-width: 0; }                  /* grid item; let the field shrink */
.je-cell-label { display: none; }           /* per-field labels are mobile-only */
.je-cell > .search-select { width: 100%; }
.je-row input {
    width: 100%;
    padding: 4px 6px;
    font-size: var(--fs-sm);
    border: 1px solid var(--line);
    border-radius: var(--r-xs);
    background: var(--input-bg);
    color: var(--navy);
}
.je-row input:focus { border-color: var(--sky); outline: none; box-shadow: 0 0 0 2px var(--sky-soft); }

/* ⚠⚠ THE BAND THE ROW WAS BROKEN IN. Between the touch floor (760px, where .li-del
   becomes 44px) and the stacking breakpoint (560px, where the grid stops existing) the
   ✕ needs a track that can hold it — the same fix .ol-row and .bl-row already carry.
   The two flexible text tracks give up the width, because the alternative is the row
   overflowing into .li-wrap's overflow:hidden and the button disappearing. */
@media (min-width: 561px) and (max-width: 760px) {
    .je-head, .je-row {
        grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr) 84px 84px minmax(0, 0.8fr) 44px;
    }
}

/* Mobile: hide the header row and stack each line's fields vertically, each with
   an inline label in front of the field. */
@media (max-width: 560px) {
    .je-head { display: none; }
    .je-row {
        display: flex;
        flex-direction: column;
        gap: 6px;
        padding: 8px;
        margin-bottom: 8px;
        border: 1px solid var(--line);
        border-radius: var(--r-sm);
        background: var(--card);
    }
    .je-cell {
        display: flex;
        align-items: center;
        gap: 8px;
        margin-right: auto;
    }
    .je-cell-label {
        display: block;
        flex: 0 0 68px;
        font-size: var(--fs-xs);
        font-weight: 700;
        text-transform: uppercase;
        letter-spacing: 0.04em;
        color: var(--navy-soft);
    }
    .je-cell > input,
    .je-cell > .search-select { flex: 1; width: auto; min-width: 0; }
    .je-cell-del { justify-content: flex-start; }
}

/* --- Inline cell editing (estimate lines, phase dates) ------------------ */
/* A bare input that reads as table text until hovered/focused, so editable
   tables stay scannable. Values commit on change (blur/Enter). */
.cell-input {
    font-family: var(--font-sans);
    font-size: inherit;
    color: var(--navy);
    background: transparent;
    border: 1px solid transparent;
    border-radius: var(--r-sm);
    padding: 3px 6px;
    outline: none;
    transition: border-color 0.12s ease, background 0.12s ease;
}
.cell-input:hover { border-color: var(--line-mid); }
/* ⚠ `:hover` was the ONLY thing that revealed an editable cell, so on a device
   with no hover the affordance never appeared at all and the estimate page
   explained it in PROSE ("click a qty or cost to edit in place") instead of
   drawing it. Painted unconditionally it turns a 28-row table into a form, so
   it is painted exactly where hover cannot do the job. */
@media (hover: none) {
    .cell-input { border-color: var(--line-soft); }
}
.cell-input:focus { border-color: var(--sky); background: var(--input-bg); box-shadow: 0 0 0 3px var(--sky-soft); }
.cell-input.num { text-align: right; width: 90px; }
.cell-input[type="date"] { width: 148px; }

/* Mobile card-collapse tables: let the inputs share the value column. */
@media (max-width: 760px) {
    .tbl-cards .cell-input { width: auto; max-width: 160px; }
}

/* --- Budget depletion bars (project Budget panel) ----------------------- */
/* Estimated -> committed -> actual per category: solid fill = actual cost,
   lighter fill = committed on top; the red tick marks where the budget ends
   when spend has passed it. Tokens only, so both themes just work. */
.depl { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; }
.depl-row { display: flex; align-items: center; gap: 10px; }
.depl-label {
    width: 120px; font-size: var(--fs-xs); text-transform: capitalize;
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.depl-track {
    position: relative; flex: 1; height: 10px; border-radius: 6px;
    background: var(--line); overflow: hidden; display: flex;
}
.depl-fill { display: block; height: 100%; }
.depl-actual { background: var(--navy); }
.depl-committed { background: var(--deep); opacity: 0.45; }   /* committed = neutral, never the redline */
.depl-overmark { position: absolute; top: 0; bottom: 0; width: 2px; background: var(--bad); }
.depl-amt { font-size: var(--fs-2xs); white-space: nowrap; }

@media (max-width: 760px) {
    .depl-label { width: 86px; }
    .depl-amt { font-size: 10px; }
}

/* ---- Field home (/field) — the foreman/crew phone home screen -------------
   Big touch targets, one column, the near-black accent card up top. Uses the
   same tokens as the rest of the app so dark mode falls out of the palette. */
.fh-wrap { max-width: 560px; margin: 0 auto; padding-bottom: 88px; }

.fh-head { margin: 14px 2px 12px; }
.fh-hello { font-size: 22px; font-weight: 700; letter-spacing: -0.02em; }
.fh-date { color: var(--text-muted); font-size: var(--fs-xs); margin-top: 2px; }

.fh-clock {
    display: flex; align-items: center; justify-content: space-between; gap: 12px;
    /* Near-black hero card in both themes (--deep stays a dark neutral in
       dark mode) — the redline accent is for actions, not banner fills. */
    background: var(--deep); color: #fff;
    border-radius: 14px; padding: 14px 16px; margin-bottom: 14px;
}
.fh-clock-info { display: flex; align-items: center; gap: 10px; min-width: 0; }
.fh-clock-dot {
    width: 10px; height: 10px; border-radius: 50%; flex: none;
    background: color-mix(in srgb, var(--card) 45%, transparent);
}
/* "On the clock" pulse: the status green lifted for the near-black hero.
   Solid first — this dot IS the clocked-in signal, so it must never fall
   back to transparent where color-mix is unsupported. */
.fh-clock-dot.fh-on {
    background: #82AE99;
    background: color-mix(in srgb, var(--ok) 55%, #fff);
    box-shadow: 0 0 8px rgba(27, 107, 69, 0.45);
    box-shadow: 0 0 8px color-mix(in srgb, var(--ok) 45%, transparent);
}
.fh-clock-line { font-weight: 700; font-size: 14px; }
.fh-clock-sub { font-size: var(--fs-2xs); opacity: 0.75; margin-top: 1px; }
.fh-punch {
    flex: none; text-decoration: none; font-weight: 700; font-size: 14px;
    color: var(--sky); background: var(--card);
    border-radius: 10px; padding: 12px 18px; min-height: 44px;
    display: inline-flex; align-items: center;
}
/* :visited twin — (0,2,0) beats `a, a:visited` (0,1,1). See VisitedCascadeTests. */
.fh-punch, .fh-punch:visited { color: var(--sky); }
.fh-punch:active { transform: scale(0.97); }

.fh-assign { margin-bottom: 8px; display: flex; flex-direction: column; gap: 8px; }
.fh-assign-top { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.fh-assign-name { font-weight: 700; font-size: 15px; color: var(--sky); text-decoration: none; }
.fh-addr { color: var(--text-muted); font-size: var(--fs-xs); text-decoration: none; }
.fh-addr:hover { color: var(--sky); }
.fh-note { font-size: var(--fs-xs); }
.fh-mates { display: flex; flex-wrap: wrap; gap: 6px; }
.fh-chip {
    font-size: var(--fs-2xs); border: 1px solid var(--line); border-radius: 999px;
    padding: 3px 9px; background: var(--paper-warm); white-space: nowrap;
}
.fh-empty { padding: 16px; color: var(--text-muted); font-size: var(--fs-xs); }
.fh-next { margin-top: 6px; }

.fh-task { display: flex; align-items: center; gap: 10px; padding: 9px 14px; }
.fh-task + .fh-task { border-top: 1px solid var(--line); }
.fh-task-main { min-width: 0; flex: 1; }
.fh-task-title { font-weight: 600; font-size: 13px; }
.fh-task-sub { color: var(--text-muted); font-size: var(--fs-2xs); margin-top: 1px; }
.fh-task-done { flex: none; min-width: 44px; min-height: 44px; font-size: 16px; }
.fh-more { display: block; padding: 9px 14px; font-size: var(--fs-xs); color: var(--sky); text-decoration: none; }

/* Sheet 04 big buttons: 48px+ targets, icon chip + title + sub, one per row —
   built for gloves and sunlight. The camera one is the near-black hero. */
.fh-bigs { display: flex; flex-direction: column; gap: 8px; }
.fh-big {
    display: flex; align-items: center; gap: 14px;
    background: var(--card); border: 1.5px solid var(--line); border-radius: 16px;
    padding: 15px 16px; text-decoration: none; color: inherit; min-height: 72px;
}
.fh-big:active { transform: scale(0.98); }
.fh-big-ic {
    width: 44px; height: 44px; border-radius: 12px; flex: none;
    display: inline-flex; align-items: center; justify-content: center;
    background: var(--paper-warm); color: var(--navy);
}
.fh-big-t { font-size: 16px; font-weight: 700; }
.fh-big-s { font-size: var(--fs-xs); color: var(--text-muted); margin-top: 2px; }
.fh-big-cam { background: var(--deep); border-color: var(--deep); color: #fff; }
/* ⚠⚠ The FIFTH instance of the a:visited fault, and the worst-placed one: these
   are <a> elements, so `a, a:visited` (0,1,1) beats both rules above (0,1,0).
   .fh-big-cam is the crew's primary action on the field home screen and its href
   is /quicklog — the page they open all day — so it is repainted accent-on-deep
   for every user who has ever used it, which is all of them. The plain .fh-big
   cards lose their `color: inherit` the same way once /mywork or /crew has been
   visited: legible, but a different colour from their unvisited siblings.
   ANY new colour rule that can land on an <a> needs its :visited twin. */
.fh-big,     .fh-big:visited     { color: inherit; }
.fh-big-cam, .fh-big-cam:visited { color: #fff; }
.fh-big-cam .fh-big-s { color: rgba(255, 255, 255, 0.72); }
.fh-big-cam .fh-big-ic { background: rgba(255, 255, 255, 0.14); color: #fff; }
.fh-big-ic.ic-pl { background: var(--stage-inprogress-bg); color: var(--stage-inprogress-fg); }
.fh-big-ic.ic-hr { background: var(--ok-light); color: var(--ok-ink); }
.fh-big-ic.ic-crew { background: color-mix(in srgb, var(--chart-purple) 12%, transparent); color: var(--chart-purple); }
.fh-big-hrs { margin-left: auto; text-align: right; flex: none; }
.fh-big-hrs b { font-size: 17px; font-weight: 700; font-variant-numeric: tabular-nums; }
.fh-big-hrs small { display: block; font-size: var(--fs-2xs); color: var(--text-muted); }
.fh-big-cam .fh-big-hrs small { color: rgba(255, 255, 255, 0.72); }

/* Job card micro-cap ("TODAY · MON JUL 21") + high-priority task dot. */
.fh-cap {
    display: block; font-family: var(--font-mono); font-size: 10.5px; font-weight: 600;
    text-transform: uppercase; letter-spacing: 0.14em; color: var(--text-muted);
}
.fh-pri {
    display: inline-block; width: 7px; height: 7px; border-radius: 50%;
    background: var(--bad); margin-left: 5px; vertical-align: 2px;
}

/* ---- Dispatch board (/dispatch) — person-per-column scheduling grid ------ */
/* Class-based grid on purpose: the mobile flatten net only matches inline
   grid-template-columns (see .gantt-grid), so the column count rides a CSS
   custom property and the board keeps its columns on phones. */

.dispatch-layout { display: flex; gap: 12px; align-items: stretch; }
.dispatch-board { flex: 1; min-width: 0; }

/* The board scrolls INSIDE this box (both axes, capped to the viewport), so
   the horizontal scrollbar is always reachable and headers stay pinned. */
.dispatch-scroll {
    overflow: auto;
    max-height: calc(100vh - 248px);
    border: 1px solid var(--line);
    border-radius: var(--r);
    background: var(--card);
}

.dispatch-grid {
    display: grid;
    grid-template-columns: 84px repeat(var(--dispatch-cols, 1), minmax(176px, 1fr));
    min-width: 100%;
    width: max-content;
}
.dispatch-grid.is-day { grid-template-columns: repeat(var(--dispatch-cols, 1), minmax(210px, 1fr)); }

.dispatch-corner,
.dispatch-colhead {
    padding: 8px 10px;
    background: var(--paper-warm);
    border-bottom: 1px solid var(--line);
    font-size: var(--fs-2xs);
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.05em;
    color: var(--text-muted);
    white-space: nowrap;
}
.dispatch-corner { position: sticky; top: 0; left: 0; z-index: 3; border-right: 1px solid var(--line); }
.dispatch-colhead { position: sticky; top: 0; z-index: 2; display: flex; gap: 6px; align-items: center; overflow: hidden; }
.dispatch-colhead .dispatch-col-name { overflow: hidden; text-overflow: ellipsis; }
.dispatch-colhead .dispatch-col-count { margin-left: auto; font-weight: 700; opacity: 0.75; }
.dispatch-colhead .dispatch-col-warn { color: var(--bad-ink); }

.dispatch-daylabel {
    position: sticky; left: 0; z-index: 1;
    background: var(--paper-warm);
    border-right: 1px solid var(--line);
    border-bottom: 1px solid var(--fill);
    padding: 8px;
    font-size: var(--fs-2xs);
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.05em;
    color: var(--text-muted);
}
.dispatch-daylabel.is-today { color: var(--navy); }

.dispatch-cell {
    position: relative;
    border-bottom: 1px solid var(--fill);
    border-right: 1px solid var(--fill);
    padding: 6px 6px 24px;
    min-height: 84px;
    display: flex;
    flex-direction: column;
    gap: 4px;
}
.dispatch-grid.is-day .dispatch-cell { min-height: 200px; }
.dispatch-cell.drop-hover { background: var(--sky-soft); box-shadow: inset 0 0 0 2px var(--line-mid); }

.dispatch-cell-add {
    position: absolute; right: 4px; bottom: 4px;
    border: 1px dashed var(--line-mid); border-radius: var(--r-xs);
    background: transparent; color: var(--text-muted);
    font-size: var(--fs-2xs); line-height: 1; padding: 3px 7px;
    cursor: pointer; opacity: 0; transition: opacity 0.12s;
}
.dispatch-cell:hover .dispatch-cell-add { opacity: 1; }

.dispatch-chip {
    border: 1px solid var(--line);
    border-left: 3px solid var(--warn);
    border-radius: var(--r-xs);
    background: var(--card);
    padding: 4px 8px;
    font-size: var(--fs-2xs);
    cursor: pointer;
    box-shadow: var(--shadow-soft);
    transition: box-shadow 0.12s;
}
.dispatch-chip:hover { box-shadow: var(--shadow-md); }
.dispatch-chip.is-confirmed { border-left-color: var(--ok); }
.dispatch-chip.is-conflict { background: var(--bad-light); border-color: var(--bad); }
.dispatch-chip.is-duplicate { background: var(--warn-light); }
.dispatch-chip.dragging { opacity: 0.35; }
.dispatch-chip.is-readonly { cursor: default; }
.dispatch-chip.is-readonly:hover { box-shadow: var(--shadow-soft); }
.dispatch-chip-name { font-weight: 600; color: var(--navy); font-size: var(--fs-xs); display: block; }
.dispatch-chip-sub { color: var(--navy-soft); display: flex; gap: 8px; flex-wrap: wrap; }

.dispatch-map {
    width: 400px; flex-shrink: 0;
    border: 1px solid var(--line); border-radius: var(--r);
    overflow: hidden; background: var(--card);
    display: flex; flex-direction: column;
    min-height: 420px;
    /* Leaflet stacks its own furniture high in the ROOT stacking context —
       panes at 400, controls (zoom, attribution) at 1000 — which painted the
       map straight over the off-canvas nav rail (260) and its backdrop (200)
       on mobile, and over the bottom nav (500). position+z-index makes this
       box its own stacking context, so every Leaflet layer is trapped inside
       it and the whole map sits at 0 against the page chrome. Keep both
       declarations: z-index only forms a context on a positioned element. */
    position: relative;
    z-index: 0;
}
.dispatch-map-canvas { flex: 1; min-height: 320px; }
.dispatch-map-note {
    padding: 8px 12px; font-size: var(--fs-2xs);
    color: var(--text-muted); border-top: 1px solid var(--line);
}

.dispatch-scope {
    border: 1px solid var(--line-mid);
    border-radius: var(--r-sm);
    background: var(--input-bg);
    color: var(--navy);
    font-size: var(--fs-xs);
    font-weight: 600;
    padding: 5px 8px;
    max-width: 190px;
}

.dispatch-legend {
    display: flex; gap: 14px; flex-wrap: wrap;
    margin-top: 8px;
    font-size: var(--fs-2xs); color: var(--text-muted);
}
.dispatch-legend span { display: inline-flex; align-items: center; gap: 5px; }
.dispatch-legend i { width: 12px; height: 10px; border-radius: 3px; display: inline-block; }
/* Confirmed/tentative are chip miniatures (the state lives on the chip's left
   edge), tinted fills mirror the conflict/duplicate backgrounds, and the round
   dots are the Day-map pin colors. */
.dispatch-legend .lg-ok { background: var(--card); border: 1px solid var(--line); border-left: 3px solid var(--ok); }
.dispatch-legend .lg-warn { background: var(--card); border: 1px solid var(--line); border-left: 3px solid var(--warn); }
.dispatch-legend .lg-conflict { background: var(--bad-light); border: 1px solid var(--bad); }
.dispatch-legend .lg-dup { background: var(--warn-light); border: 1px solid var(--warn); }
.dispatch-legend .lg-pin-ok { width: 10px; border-radius: 50%; background: var(--chart-blue); }
.dispatch-legend .lg-pin-warn { width: 10px; border-radius: 50%; background: var(--warn); }

@media (max-width: 1080px) {
    .dispatch-layout { flex-direction: column; }
    .dispatch-map { width: 100%; min-height: 340px; }
}

@media (max-width: 760px) {
    .dispatch-scroll { max-height: calc(100vh - 200px); }
}

/* --- Home dashboard strips (module-contributed: money / week) -----------
   Home stacks blocks that no single file owns: the greeting, then whatever
   strips the modules contribute, then the cards. They must read as ONE rhythm,
   so every gap in the stack is 12px — the value .bp-cols and .bp-projhead
   already use. Measured at 7 widths in Design/homegap-probe.html.
   ⚠ .box carries NO margin of its own, so a bare .box between two strips sat
   flush against the one below it: the Books-check note and the crew strip were
   TOUCHING at every width. Anything dropped into this stack needs .home-note
   (or its own 12px) — it will not inherit spacing from .box. */
.home-strip { margin: 0 0 12px; }
.home-note  { margin: 12px 0; }
a.home-kpi { text-decoration: none; display: block; transition: border-color .12s ease; }
a.home-kpi:hover { border-color: var(--navy-soft); }

/* --- Cash-flow forecast (The Books) ------------------------------------- */
/* A plan-room reading of a forecast: the week rows are the drawing, the bar
   column is the section cut through it. Hairlines and tint only — no shadow,
   no gradient, nothing that says "chart widget". */

/* Drawing-title-block caption, the mockup's micro-cap, for a panel that isn't
   a KPI tile. Same spec as .kpi-box .kpi-label so captions match across pages. */
.bp-caption {
    font-family: var(--font-mono);
    font-size: var(--fs-2xs);   /* was 10.5px — see the micro-label floor note in :root */
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.16em;
    color: var(--ink3);
    margin-bottom: 9px;
}

/* The balance bar. Fixed column so the money columns never jump between rows;
   it is decoration for the number beside it, so it hides on a phone where the
   table has already collapsed to cards. */
.cf-barcol { width: 132px; }
.cf-bar {
    height: 8px;
    border-radius: var(--r-pill);
    background: var(--fill-soft);
    overflow: hidden;
}
.cf-bar-fill {
    height: 100%;
    border-radius: inherit;
    background: var(--navy-soft);
    min-width: 2px;
}
/* Going under is the one thing on this page that earns the redline. */
.cf-bar-neg { background: var(--bad-ink); }
.cf-tbl .cf-row-bad td { background: var(--bad-light); }

.cf-caret { display: inline-block; width: 12px; color: var(--text-muted); font-size: 10px; }

/* Drill-down rows: a well under the week they belong to, indented so the eye
   reads them as contents rather than as more weeks. */
.cf-tbl .cf-detail td {
    background: var(--fill-soft);
    padding-top: 6px;
    padding-bottom: 6px;
    font-size: var(--fs-xs);
}
.cf-tbl .cf-detail td:first-child { padding-left: 30px; }

.cf-assume { margin: 0; padding-left: 18px; font-size: var(--fs-xs); color: var(--text-muted); line-height: 1.7; }
.cf-assume li + li { margin-top: 5px; }
.cf-assume strong { color: var(--navy); font-weight: 600; }

/* Anything the machine wrote wears the violet — a tinted well, not a card.
   Solid border-color FIRST: where color-mix is unsupported the whole
   declaration is dropped, and without a fallback the box would fall back to
   .box's grey hairline instead of a violet edge. */
.ai-note { background: var(--ai-tint); border-color: var(--ai); }
.ai-note { border-color: color-mix(in srgb, var(--ai) 25%, transparent); }
.ai-note-head { display: flex; align-items: center; gap: 8px; margin-bottom: 7px; }

@media (max-width: 760px) {
    .cf-barcol { display: none; }
    .cf-tbl .cf-detail td:first-child { padding-left: 12px; }
}

/* ---- Boards (/boards) — the company task board -------------------------
   A two-axis grid: columns across, named swimlanes down, cards in the cells.

   Modelled on .dispatch-grid, NOT on .kanban-* . The kanban block above is
   Pipeline's read-only ONE-dimensional flex board with 200px columns; lanes
   make this board genuinely two-dimensional, and widening those shared classes
   would change Pipeline's appearance with no test to catch it. Dispatch already
   solved this exact shape — sticky headers, horizontal scroll, a column count
   that survives the phone flatten rule — so this borrows from there. */

.boards-scroll {
    overflow: auto;
    max-height: calc(100vh - 268px);
    border: 1px solid var(--line);
    border-radius: var(--r);
    background: var(--card);
}

/* The column count rides a CUSTOM PROPERTY, not an inline grid-template. The
   phone rule further down flattens any inline grid-template-columns to 1fr,
   which would collapse the whole board — the same trap .dispatch-grid documents. */
.boards-board {
    display: grid;
    grid-template-columns: 132px repeat(var(--boards-cols, 1), minmax(272px, 1fr));
    min-width: 100%;
    width: max-content;
}

/* Lanes are optional. With none, the board drops the whole lane-label track —
   the leading 132px — rather than rendering an empty one, and the markup drops
   .boards-corner / .boards-lanehead to match. Track count and cell count MUST
   agree: leave the 132px here and every cell shifts one column right, which
   reads as the board being subtly broken rather than as a missing label. */
.boards-board.no-lanes {
    grid-template-columns: repeat(var(--boards-cols, 1), minmax(272px, 1fr));
}

.boards-corner,
.boards-colhead {
    padding: 9px 11px;
    background: var(--paper-warm);
    border-bottom: 1px solid var(--line);
    font-size: var(--fs-2xs);
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.05em;
    color: var(--text-muted);
    white-space: nowrap;
}
.boards-corner  { position: sticky; top: 0; left: 0; z-index: 3; border-right: 1px solid var(--line); }
.boards-colhead { position: sticky; top: 0; z-index: 2; display: flex; gap: 6px; align-items: center; overflow: hidden; }
.boards-colhead-name { overflow: hidden; text-overflow: ellipsis; }

/* WIP counts the COLUMN across every lane — that is what the limit means on a
   board with swimlanes. Advisory only: a drop is never rejected, it just says so. */
.boards-wip { margin-left: auto; font-weight: 700; font-variant-numeric: tabular-nums; opacity: 0.75; }
.boards-colhead.is-atwip  { background: var(--warn-light); color: var(--warn-ink); }
.boards-colhead.is-overwip { background: var(--bad-light);  color: var(--bad-ink); }
.boards-colhead.is-atwip .boards-wip,
.boards-colhead.is-overwip .boards-wip { opacity: 1; }

.boards-lanehead {
    position: sticky; left: 0; z-index: 1;
    background: var(--paper-warm);
    border-right: 1px solid var(--line);
    border-bottom: 1px solid var(--fill);
    padding: 9px 10px;
    font-size: var(--fs-2xs);
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.05em;
    color: var(--text-muted);
    overflow: hidden;
    text-overflow: ellipsis;
}

.boards-cell {
    position: relative;
    border-bottom: 1px solid var(--fill);
    border-right: 1px solid var(--fill);
    padding: 7px 7px 26px;
    min-height: 96px;
    display: flex;
    flex-direction: column;
    gap: 5px;
}
.boards-cell.drop-hover { background: var(--sky-soft); box-shadow: inset 0 0 0 2px var(--line-mid); }

.boards-cell-add {
    position: absolute; right: 5px; bottom: 5px;
    border: 1px dashed var(--line-mid); border-radius: var(--r-xs);
    background: transparent; color: var(--text-muted);
    font-size: var(--fs-2xs); line-height: 1; padding: 4px 8px;
    cursor: pointer; opacity: 0; transition: opacity 0.12s;
}
.boards-cell:hover .boards-cell-add,
.boards-cell-add:focus-visible { opacity: 1; }

.boards-card {
    border: 1px solid var(--line);
    border-left: 3px solid var(--line-mid);
    border-radius: var(--r-xs);
    background: var(--card);
    padding: 7px 9px;
    box-shadow: var(--shadow-soft);
    cursor: pointer;
    transition: box-shadow 0.12s;
    display: flex;
    flex-direction: column;
    gap: 3px;
}
.boards-card:hover { box-shadow: var(--shadow-md); }
.boards-card.dragging { opacity: 0.35; }
.boards-card.is-readonly { cursor: default; }
.boards-card.is-readonly:hover { box-shadow: var(--shadow-soft); }
.boards-card.is-done { border-left-color: var(--ok); }
.boards-card-name { font-weight: 600; color: var(--navy); font-size: var(--fs-xs); }
.boards-card-sub  { color: var(--navy-soft); font-size: var(--fs-2xs); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.boards-card-foot { display: flex; align-items: center; gap: 6px; margin-top: 10px; }

/* The drag handle doubles as the touch and keyboard path, so it is visible at
   EVERY width — HTML5 drag never fires on touch, and a fallback nobody can find
   is not a fallback. */
.boards-card-handle {
    margin-left: auto;
    border: none; background: transparent; cursor: pointer;
    color: var(--text-muted); font-size: 13px; line-height: 1;
    padding: 4px 6px; border-radius: var(--r-xs);
}
.boards-card-handle:hover { background: var(--fill); color: var(--navy); }

/* Insertion targets between cards, rendered ONLY while a drag is in flight so
   an idle board's DOM is unchanged. Dropping onto a card would read as "nest
   inside", which this model has no concept of; a line says "insert here". */
.boards-dropline { height: 6px; border-radius: 3px; margin: -2px 0; }
.boards-dropline.on { background: var(--sky); box-shadow: 0 0 0 2px var(--sky-soft); }

.boards-legend { display: flex; gap: 14px; flex-wrap: wrap; margin-top: 8px; font-size: var(--fs-2xs); color: var(--text-muted); }

/* Board tiles on /boards */
.boards-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 10px; }
.boards-tile { display: flex; flex-direction: column; gap: 4px; text-decoration: none; }
.boards-tile-name { font-size: var(--fs-md); font-weight: 650; color: var(--navy); }
/* The template tile's name is a real <a>, and app.css sets no global anchor
   colour — so without this it fell back to the browser's default link blue,
   which is unreadable on the dark card. `inherit` rather than a fixed colour:
   this is a title, not a link in prose, and --navy already resolves to the
   right ink in both themes. A hardcoded white would vanish on the light card. */
.boards-tile-name a { color: inherit; text-decoration: none; }
.boards-tile-name a:hover { text-decoration: underline; }

/* Links that ARE links — a card's saved URLs and its attachment names, both
   inside the drawer. These keep looking like links, in the house accent that
   is already tuned per theme (--brand lightens to #5B9CFF on dark). */
.boards-link { color: var(--sky); text-decoration: none; font-weight: 600; }
.boards-link:hover { text-decoration: underline; }
.boards-tile-meta { display: flex; gap: 10px; flex-wrap: wrap; font-size: var(--fs-2xs); color: var(--text-muted); }
.boards-stats { margin-top:auto;border:1px solid gray;border-radius:.425rem;justify-content: center; }
@media (max-width: 760px) {
    /* A 2D grid cannot usefully shrink, so the board shows ONE column at a time
       (the page renders a pager) and the lane label row collapses to a heading. */
    .boards-scroll { max-height: none; }
    .boards-board { grid-template-columns: 96px minmax(0, 1fr); width: auto; }
    .boards-board.no-lanes { grid-template-columns: minmax(0, 1fr); }
    .boards-cell { min-height: 72px; }
    .boards-card-handle { padding: 8px 10px; }   /* real tap target */
}

/* Boards phase 2 — what a card carries. Due state is never colour alone: the
   chip spells it out ("2 days late"), the rail just makes it findable. */
.boards-card.is-overdue { border-left-color: var(--bad); }
.boards-card.is-duesoon { border-left-color: var(--warn); }

.boards-due {
    font-size: var(--fs-2xs); font-weight: 600;
    padding: 2px 7px; border-radius: var(--r-pill);
    background: var(--fill); color: var(--navy-soft); white-space: nowrap;
}
.boards-due.is-late { background: var(--bad-light);  color: var(--bad-ink); }
.boards-due.is-soon { background: var(--warn-light); color: var(--warn-ink); }

.boards-prio { font-weight: 700; color: var(--warn-ink); margin-right: 3px; }
.boards-prio.is-urgent { color: var(--bad-ink); }

.boards-card-labels { display: flex; gap: 3px; flex-wrap: wrap; }
.boards-card-labels .pill { font-size: 10px; padding: 1px 6px; }

.boards-checkbar { display: block; height: 3px; border-radius: 2px; background: var(--fill); overflow: hidden; margin: 10px 0; }
.boards-checkbar-fill { display: block; height: 100%; background: var(--ok); }

.boards-card-avatars { display: inline-flex; align-items: center; gap: 3px; margin-left: auto; }
.boards-card-avatars .avatar { width: 20px; height: 20px; font-size: 8px; }
/* The handle keeps the far right — the avatars stop short of it. */
.boards-card-avatars + .boards-card-handle { margin-left: 4px; }

/* Nobody assigned. Quiet on purpose — it is a state to notice, not an alarm,
   and a red badge on every fresh card would train people to ignore it. */
.boards-unassigned {
    font-size: 10px; font-weight: 600; letter-spacing: 0.02em;
    color: var(--text-muted); border: 1px dashed var(--line-mid);
    border-radius: 999px; padding: 1px 7px; white-space: nowrap;
}

/* The checklist disclosure. It sits in the card foot beside the other counts
   and has to read as a control without becoming a button-looking thing on a
   tile that is itself clickable — so: no border, no fill, just the caret. */
.boards-steptoggle {
    display: inline-flex; align-items: center; gap: 3px;
    background: none; border: 0; padding: 0; margin: 0;
    font: inherit; color: var(--navy-soft); cursor: pointer;
}
.boards-steptoggle:hover { color: var(--navy); }
.boards-steptoggle-caret { font-size: 18px; line-height: 1; opacity: 0.8; }
/* The list's name. Allowed to truncate — the count after it must never be
   pushed off the tile, since that is the part you read at a glance. */
.boards-steptoggle-name {
    font-weight: 600;
    overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
    max-width: 11ch;
}

/* What is left to do, once expanded. Indented under the progress bar so it
   reads as belonging to it rather than as more card body. */
.boards-card-steps {
    list-style: none; margin: 2px 0 0; padding: 0 0 0 2px;
    display: flex; flex-direction: column; gap: 2px;
    border-left: 2px solid var(--line); padding-left: 7px;
}
.boards-card-steps li {
    font-size: var(--fs-2xs); color: var(--navy-soft);
    overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* A real checkbox now, so no glyph. Kept tight and small: this is a tile, and
   the row still has to read as one line of text with a box in front of it. */
.boards-step { display: flex; align-items: center; gap: 5px; cursor: pointer; }
.boards-step input { width: 12px; height: 12px; flex: 0 0 12px; margin: 0; cursor: pointer; }
.boards-step span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.boards-step input:disabled, .boards-step input:disabled + span { cursor: default; opacity: 0.6; }

/* No Save button on a card, so the footer says what it is doing instead. Lives
   in .modal-footer-start, opposite Close — no top padding, because the footer
   already centres it against the buttons. */
.boards-autosave {
    font-size: var(--fs-2xs); color: var(--text-muted);
    overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.boards-autosave.is-error { color: var(--bad-ink); font-weight: 600; }
/* Ticked steps stay on the tile but recede — same treatment the card drawer
   gives them, so the two lists read the same way. */
.boards-card-steps li.is-ticked span { text-decoration: line-through; color: var(--text-muted); }
.boards-card-steps li.is-more { color: var(--text-muted); font-style: italic; }
.boards-card-steps li.is-more::before { content: ""; }
/* A list heading, shown only when a card has more than one. */
.boards-card-steps li.is-listname {
    font-weight: 700; color: var(--text-muted);
    text-transform: uppercase; letter-spacing: 0.04em; font-size: 10px;
    margin-top: 3px;
}
.boards-card-steps li.is-listname:first-child { margin-top: 0; }
.boards-card-steps li.is-listname::before { content: ""; }

.boards-mine-group { margin-bottom: 16px; }

/* Boards phase 3 — files, discussion, history. All of it lives inside the card
   drawer, so these are list rows in a modal, not page furniture. */
.boards-file-thumb {
    width: 40px; height: 40px; flex: 0 0 40px;
    border-radius: var(--r-sm); border: 1px solid var(--line);
    object-fit: cover; background: var(--fill);
}
/* The non-image case is the same box with a glyph centred in it, so a mixed
   list of screenshots and PDFs still lines up. */
.boards-file-thumb.is-doc {
    display: inline-flex; align-items: center; justify-content: center;
    font-size: 18px;
}

.boards-comment { display: flex; gap: 8px; align-items: flex-start; }
/* pre-wrap keeps the line breaks someone typed. The text is rendered by Razor,
   which escapes it — a comment is never markup. */
.boards-comment-body {
    white-space: pre-wrap; overflow-wrap: anywhere;
    font-size: var(--fs-xs); margin-top: 2px;
}

.boards-act {
    display: flex; gap: 8px; align-items: baseline;
    font-size: var(--fs-2xs); color: var(--text-muted);
    padding: 3px 0; border-bottom: 1px solid var(--line-soft);
}
.boards-act:last-child { border-bottom: 0; }
.boards-act-icon { flex: 0 0 16px; text-align: center; }
.boards-act-when { margin-left: auto; white-space: nowrap; color: var(--text-muted); }

/* ---- Detail-page head: hero + property rail ----------------------------- */
/* Replaces the four-tile .kpi-strip on a detail page with one dominant number
   and a quiet key/value rail. Same figures, one hierarchy instead of four boxes
   competing — and it dissolves the four-tile cap, because the rail grows by a
   ROW, not by a column that crushes its neighbours.
   Deliberately generic (`bp-`, no `est-`): Estimate leads, then Lead, Project,
   Employee and Vehicle detail take the same head. Nothing here may assume money. */
.bp-head {
    display: grid;
    grid-template-columns: 1fr 272px;
    border: 1px solid var(--line);
    border-radius: var(--r);
    background: var(--card);
    margin-bottom: 14px;
    overflow: hidden;
}
/* Centred in its own cell on purpose. The grid makes this column as tall as the
   rail beside it, and the rail is usually the taller of the two — so a hero with
   no split bar (Lead, Employee, Vehicle, and the estimate page for anyone without
   ViewCosts) left a dead band of whitespace under the number that read as a
   half-rendered card. Centring spends that space on both sides instead, which
   reads as deliberate. */
.bp-hero {
    padding: 18px 20px 17px;
    min-width: 0;
    display: flex;
    flex-direction: column;
    justify-content: center;
}
.bp-hero-lbl {
    font-family: var(--font-mono);
    font-size: var(--fs-2xs);   /* was 10.5px — see the micro-label floor note in :root */ font-weight: 600;
    text-transform: uppercase; letter-spacing: 0.16em;
    color: var(--ink3);
}
.bp-hero-n {
    font-size: 38px; font-weight: 650; line-height: 1.05;
    letter-spacing: -0.025em; color: var(--navy);
    font-variant-numeric: tabular-nums;
    margin: 5px 0 3px;
    overflow-wrap: normal;
}
.bp-hero-sub { font-size: var(--fs-xs); color: var(--ink3); }

/* One stacked bar instead of two equal tiles: the SHAPE carries the ratio, so
   the two figures below it stop competing for the same visual weight. */
.bp-split {
    display: flex; height: 9px; border-radius: var(--r-pill);
    overflow: hidden; background: var(--fill);
    margin: 15px 0 7px; max-width: 430px;
}
.bp-split i { display: block; height: 100%; }
.bp-split-a { background: var(--navy-soft); flex: none; }
.bp-split-b { background: var(--accent); flex: 1; }

/* PROGRESS variant — swaps which half carries the accent.
   ⚠ Without this the same bar means opposite things on two pages. In the default
   split the leading share is the part you keep quiet about (cost) and the accent
   remainder is the good half (margin), so the accent GROWS as things improve. On
   a project the leading share is what you have BILLED, and progress-bar convention
   says a full accent bar means done — so an unbilled job rendered 100% accent
   beside the words "$0 billed · 0%". Measured on PRJ-000085. Here the accent is
   the progress and the remainder goes quiet, which is the reading everyone
   already has for a filled bar. */
.bp-split-progress .bp-split-a { background: var(--accent); }
.bp-split-progress .bp-split-b { background: var(--fill); }
.bp-split-lbl {
    display: flex; justify-content: space-between; gap: 12px;
    max-width: 430px; font-size: var(--fs-xs); color: var(--text-muted);
    font-variant-numeric: tabular-nums; flex-wrap: wrap;
}

.bp-rail { border-left: 1px solid var(--line); background: var(--paper-warm); min-width: 0; }
.bp-rail-r {
    display: flex; justify-content: space-between; align-items: center;
    gap: 10px; padding: 6px 16px;
    font-size: var(--fs-xs);
    border-bottom: 1px solid var(--line-soft);
    min-height: 34px;
}
.bp-rail-r:last-child { border-bottom: none; }
.bp-rail-k { color: var(--ink3); flex: none; }
.bp-rail-v { font-weight: 550; text-align: right; min-width: 0; overflow-wrap: anywhere; }
.bp-rail-in { width: 62px; padding: 2px 5px; }
.bp-rail-sel { width: auto; text-align: left; }
/* The rail's editable values are the ONE place a dashed underline is used, so
   "you can change this" is drawn rather than left to hover discovery. */
.bp-rail-in:not(:focus) { border-bottom: 1px dashed var(--line-mid); border-radius: 0; }

/* The ⋯ More panel. A disclosure strip rather than a floating menu: no overlay
   positioning to get wrong, and Delete's confirm expands INSIDE it instead of
   reflowing the action row so "Yes, delete" lands where "Edit" just was. */
/* ⚠ `.bp-more` / `.bp-more.on` WERE HERE and are gone (Andre, 2026-08-12): the
   ⋯ More disclosure has been removed from all five detail pages and every action it
   hid now sits on the header row. If you are here because some markup still says
   `bp-more`, that markup is stale — the class no longer exists and the panel will
   render unstyled rather than hidden, which is the loud failure, not a silent one. */

@media (max-width: 760px) {
    .bp-head { grid-template-columns: 1fr; }
    .bp-rail { border-left: none; border-top: 1px solid var(--line); }
    .bp-hero-n { font-size: 32px; }
}

/* ---- Segmented switch (which view of a record) -------------------------- */
.bp-seg { display: inline-flex; border: 1px solid var(--line); border-radius: var(--r-sm); overflow: hidden; }
.bp-seg button {
    font: inherit; font-size: var(--fs-xs);
    padding: 5px 12px; border: none; background: transparent;
    color: var(--text-muted); cursor: pointer; white-space: nowrap;
}
.bp-seg button + button { border-left: 1px solid var(--line); }
.bp-seg button.on { background: var(--deep); color: var(--card); font-weight: 600; }
.bp-seg button:not(.on):hover { background: var(--fill); color: var(--navy); }

/* ---- The client's document, shown in-app ------------------------------- */
/* Reads as a document, not a grid: no unit rates, no quantities, generous
   rhythm, and the money right-aligned on one edge. */
.bp-doc { margin-top: 8px; }
.bp-doc-h {
    font-family: var(--font-mono); font-size: 10.5px; font-weight: 600;
    text-transform: uppercase; letter-spacing: 0.14em; color: var(--ink3);
    padding-bottom: 9px; border-bottom: 1px solid var(--line); margin-bottom: 4px;
}
.bp-doc-r {
    display: flex; justify-content: space-between; gap: 20px;
    padding: 9px 0; border-bottom: 1px solid var(--line-soft);
    font-size: var(--fs-sm);
}
.bp-doc-r .num { font-variant-numeric: tabular-nums; white-space: nowrap; }
.bp-doc-t {
    display: flex; justify-content: space-between; gap: 20px;
    margin-top: 10px; padding: 11px 0 4px;
    border-top: 2px solid var(--navy);
    font-size: 18px; font-weight: 650; font-variant-numeric: tabular-nums;
}
/* Scope in words, no money — "one price, work listed". Sits ABOVE the price so
   the client reads what they are buying before what it costs. */
.bp-doc-scope { margin: 4px 0 18px; }
.bp-doc-s {
    font-size: var(--fs-sm); padding: 5px 0 5px 14px;
    border-bottom: 1px solid var(--line-soft); position: relative;
}
.bp-doc-s::before { content: "•"; position: absolute; left: 2px; color: var(--ink3); }

.bp-doc-x { margin-top: 22px; }
.bp-doc-xh {
    font-family: var(--font-mono); font-size: 10px; font-weight: 600;
    text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink3);
    padding-bottom: 6px; border-bottom: 1px solid var(--line-soft);
}

/* ---- Estimate line items: category bands (EstimateDetail) ---------------- */
/* Grouping is a VIEW over unchanged data — no column was added and nothing is
   persisted, so a second axis (phase of work) drops in later as another option.
   The band row carries the customer-facing section label from
   EstimatePresenter.Sections and the band's COST.
   ⚠ Those same five labels carry the customer PRICE on the proposal PDF and the
   signing page, because GroupedRows applies markup — which is why the band
   figure is labelled "cost" in the markup rather than left bare. */

:root {
    --est-cat-labor:  #B0741C;
    --est-cat-mat:    #2E6E8E;
    --est-cat-equip:  #6B6257;
    --est-cat-sub:    #7A5BA6;
    --est-cat-other:  #6E6862;
}
[data-theme="dark"] {
    --est-cat-labor:  #D9A34E;
    --est-cat-mat:    #6FAECB;
    --est-cat-equip:  #A79C8E;
    --est-cat-sub:    #AE92D8;
    --est-cat-other:  #8B837B;
}

/* Tint is spent on band rows and totals ONLY, so a tinted row always means
   "this is not a line item". Band rows also opt out of the tbody hover at
   app.css:2209 — they are not data and must not light up like it. */
.ln-tbl tbody tr.ln-band > td,
.ln-tbl tbody tr.ln-band:hover > td {
    background: var(--paper-warm);
    border-top: 1px solid var(--line);
    border-bottom: 1px solid var(--line);
    padding-top: 7px;
    padding-bottom: 7px;
}
.ln-band-btn { font-family: inherit; }
.ln-band-label {
    font-family: var(--font-mono);
    font-size: var(--fs-2xs);   /* was 10.5px — see the micro-label floor note in :root */
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.12em;
    color: var(--navy-soft);
}
.ln-band-count { font-size: var(--fs-2xs); color: var(--text-muted); margin-left: 6px; }
.ln-band-sum { font-weight: 600; }
.ln-band-cost {
    font-family: var(--font-mono);
    font-size: var(--fs-2xs);   /* was 9.5px — see the micro-label floor note in :root */
    font-weight: 500;
    text-transform: uppercase;
    letter-spacing: 0.1em;
    color: var(--text-muted);
    margin-right: 7px;
}

/* Category as a 3px edge rather than a repeated grey pill. The pill occupied the
   widest low-value column in the table and rendered every category in the same
   neutral fill — full visual cost, zero signal. */
.ln-tbl tbody tr.ln-row > td:first-child          { box-shadow: inset 3px 0 0 var(--est-cat-other); }
.ln-tbl tbody tr.ln-cat-labor > td:first-child       { box-shadow: inset 3px 0 0 var(--est-cat-labor); }
.ln-tbl tbody tr.ln-cat-materials > td:first-child   { box-shadow: inset 3px 0 0 var(--est-cat-mat); }
.ln-tbl tbody tr.ln-cat-equipment > td:first-child   { box-shadow: inset 3px 0 0 var(--est-cat-equip); }
.ln-tbl tbody tr.ln-cat-subcontract > td:first-child { box-shadow: inset 3px 0 0 var(--est-cat-sub); }

/* Terminate the math the way a contractor reads it: a rule above the subtotal,
   a heavy rule above the grand total, a double rule under it. Nothing on this
   page closed a calculation before — `table.tbl tbody tr:last-child td
   { border-bottom: none }` stripped the rule off precisely the grand-total row. */
.ln-tbl tbody tr.ln-total > td,
.ln-tbl tbody tr.ln-total:hover > td {
    background: var(--paper-warm);
    border-top: 1px solid var(--line);
    border-bottom: none;
}
.ln-tbl tbody tr.ln-grand > td { border-top: 2px solid var(--navy); padding-bottom: 12px; }
.ln-tbl tbody tr.ln-grand > td.num { border-bottom: 3px double var(--navy); }

/* The row gutter: drag grip + a ⋮ that opens an action row beneath the line.
   Nothing at rest, revealed on hover or focus — modelled on .boards-cell-add,
   which is the one precedent here that remembers the :focus-visible arm.
   The ⋮ is built on .btn/.btn-sm on purpose: those already carry the 44px floor
   inside the phone block, so the control is touch-clean without this file
   declaring a single min-height of its own. */
.ln-gutter { width: 74px; white-space: nowrap; text-align: right; }
.ln-grip {
    cursor: grab;
    color: var(--glyph-ui);
    font-size: 14px;
    margin-right: 2px;
    opacity: 0;
    transition: opacity 0.12s;
}
.ln-grip:active { cursor: grabbing; }
.ln-kebab { opacity: 0; transition: opacity 0.12s; }
.ln-tbl tbody tr.ln-row:hover .ln-grip,
.ln-tbl tbody tr.ln-row:hover .ln-kebab,
.ln-tbl tbody tr.ln-row:focus-within .ln-grip,
.ln-tbl tbody tr.ln-row:focus-within .ln-kebab,
.ln-kebab:focus-visible { opacity: 1; }

/* The row being dragged, so the pointer is not the only feedback. */
.ln-tbl tbody tr.ln-dragging > td { opacity: 0.45; }

/* The action row. Tinted because it is not a line item — same rule the bands
   and totals follow — but in the accent tint so it never reads as a subtotal. */
.ln-tbl tbody tr.ln-actions > td,
.ln-tbl tbody tr.ln-actions:hover > td {
    background: var(--sky-soft);
    border-bottom: 1px solid var(--line);
    padding-top: 8px;
    padding-bottom: 8px;
}

@media (max-width: 760px) {
    /* No hover on touch, so the gutter is always visible — and the grip goes
       away entirely, because HTML5 drag never fires there. Move up / Move down
       in the action row is the reorder path on this device, not a fallback. */
    .ln-grip { display: none; }
    .ln-kebab { opacity: 1; }
    .tbl-cards tbody tr.ln-actions { grid-template-columns: 1fr; }

    /* The description IS the card title down here, so the little viewer button
       that existed to reach a hidden description is now redundant chrome sitting
       above every title. */
    .tbl-cards tbody tr.ln-row .desc-view-btn { display: none; }

    /* Out-specify `td:not([data-label]) { grid-column: 1 / -1 }`, which parks the
       ⋮ on a full-width row of its own at the bottom of every card. It belongs
       beside the extended cost. */
    .tbl-cards tbody tr.ln-row > td.ln-gutter {
        grid-column: auto;
        width: auto;
        align-self: end;
        text-align: right;
    }

    /* The band stays a band when the rows become cards — the grouping IS the
       design, and .tbl-cards tbody tr would otherwise draw it as another card. */
    .tbl-cards tbody tr.ln-band {
        display: flex;
        align-items: center;
        justify-content: space-between;
        gap: 10px;
        margin: 12px 0 6px;
        padding: 8px 14px;
        border: none;
        border-radius: 0;
        box-shadow: none;
        background: var(--paper-warm);
    }
    .tbl-cards tbody tr.ln-band > td { display: block; padding: 0; background: transparent; border: none; }
    .tbl-cards tbody tr.ln-band > td[data-label]::before { display: none; }
    .tbl-cards tbody tr.ln-band > td.num { text-align: right; }

    /* On a card the edge belongs to the whole card, not to one cell inside it. */
    .tbl-cards tbody tr.ln-row > td:first-child { box-shadow: none; }
    .tbl-cards tbody tr.ln-row             { border-left: 3px solid var(--est-cat-other); }
    .tbl-cards tbody tr.ln-cat-labor       { border-left-color: var(--est-cat-labor); }
    .tbl-cards tbody tr.ln-cat-materials   { border-left-color: var(--est-cat-mat); }
    .tbl-cards tbody tr.ln-cat-equipment   { border-left-color: var(--est-cat-equip); }
    .tbl-cards tbody tr.ln-cat-subcontract { border-left-color: var(--est-cat-sub); }

    /* Totals are not cards either. */
    .tbl-cards tbody tr.ln-total {
        display: flex; justify-content: space-between; gap: 10px;
        margin: 0; padding: 8px 14px;
        border: none; border-radius: 0; box-shadow: none;
        background: transparent;
    }
    .tbl-cards tbody tr.ln-total > td { display: block; padding: 0; }
    .tbl-cards tbody tr.ln-total > td[data-label]::before { display: none; }
    .tbl-cards tbody tr.ln-grand { border-top: 2px solid var(--navy); }
}

/*  --- INSTRUMENT wave 2, second pass: the two overloaded tokens ---------
    Both of these were found by re-measuring, not by reading, and both are the same
    shape: a token that means one thing as TEXT and another as a FILL.

    ⚠⚠ --navy IS BOTH. It is `color:` at 113 selectors in this file and `background:`
    at about thirty more (avatars, pills, progress fills, chat bubbles, the portal CTAs,
    QuickLog's category keys). The chassis remap re-points it at bone, which is right
    113 times and catastrophic the other thirty: a bone fill under `color: var(--card)`
    is bone on bone. Text wins the token; the fills get their own pair below, which is
    a FILL in both faces rather than an ink that happens to be dark today.

    ⚠⚠ .btn WAS THE SAME MISTAKE IN REVERSE, and it cost 293 of the first pass's 404
    failures. Putting .btn in the face-reset gave it `color: var(--face-ink)` — the same
    ink as --accent, so .btn-primary rendered #101314 on #101314, an exact fg == bg
    match. Taking it out then left it inheriting the CHASSIS tier onto its own bone
    fill: 1.52:1. A button paints its own background and therefore owns its own ink;
    it is neither chassis nor face. So it is set here, and the variants that paint a
    DIFFERENT fill re-assert after it — order matters, because wave 1's .btn-primary
    rules sit earlier in this file at equal specificity and would otherwise lose.       */

[data-theme^="instrument"] .btn {
    --ink:        var(--face-ink);
    --navy:       var(--face-ink);
    --navy-soft:  var(--face-ink2);
    --text-muted: var(--face-ink2);
    --glyph-ui:   var(--face-ink2);
    color: var(--face-ink);
}

/* ...and every button that paints something OTHER than the face re-asserts its ink. */
[data-theme="instrument-day"] .btn-primary,
[data-theme="instrument-day"] .btn-primary:hover,
[data-theme="instrument-day"] .btn-sky,
[data-theme="instrument-day"] .btn-sky:hover   { color: #FFFFFF; }
[data-theme="instrument-night"] .btn-primary,
[data-theme="instrument-night"] .btn-primary:hover,
[data-theme="instrument-night"] .btn-sky,
[data-theme="instrument-night"] .btn-sky:hover { color: #101314; }
[data-theme="instrument-day"] .btn-danger,
[data-theme="instrument-day"] .btn-danger:hover { color: #FFFFFF; }
[data-theme="instrument-night"] .btn-danger,
[data-theme="instrument-night"] .btn-danger:hover { color: #101314; }

/*  The solid key fill. A machined key is dark-on-bone by day and bone-on-dark by night,
    so this pair inverts with the face while --navy stays the text ink it mostly is.
    ⚠ The .razor selectors below live in plain (non-isolated) <style> blocks that render
    AFTER this stylesheet, so they win at equal specificity — the [data-theme] prefix is
    what makes these rules land. */
[data-theme^="instrument"] .topbar-avatar,
[data-theme^="instrument"] .avatar,
[data-theme^="instrument"] .mobilenav-camera-circle,
[data-theme^="instrument"] .pill-navy,
[data-theme^="instrument"] .pill-deep,
[data-theme^="instrument"] .progress-fill,
[data-theme^="instrument"] .accordion-header.open,
[data-theme^="instrument"] .askw-fab,
[data-theme^="instrument"] .askw-user,
[data-theme^="instrument"] .askw-send,
[data-theme^="instrument"] .receipt-capture-cta,
[data-theme^="instrument"] .depl-actual,
[data-theme^="instrument"] .depl-committed,
[data-theme^="instrument"] .fh-clock,
[data-theme^="instrument"] .fh-big-cam,
[data-theme^="instrument"] .bp-seg button.on,
[data-theme^="instrument"] .ql-photo-btn,
[data-theme^="instrument"] .ql-voice-btn,
[data-theme^="instrument"] .ql-cat.active,
[data-theme^="instrument"] .cmp-badge,
[data-theme^="instrument"] .cmp-choose.is-reco,
[data-theme^="instrument"] .cf-send,
[data-theme^="instrument"] .it-user,
[data-theme^="instrument"] .it-send,
[data-theme^="instrument"] .pc-user,
[data-theme^="instrument"] .portal-btn-pay,
[data-theme^="instrument"] .pp-approve,
[data-theme^="instrument"] .sign-submit {
    background: var(--key-fill);
    color: var(--key-fill-ink);
    border-color: var(--key-fill);
}

/*  The bay-mounted callout inside a bone card. .help-warn paints var(--paper-warm) —
    the BAY — while sitting inside a .box, so it inherited face ink onto housing. It is
    the only one of its kind, and it is 48 of the failures on /help alone. */
[data-theme^="instrument"] .help-doc .help-warn {
    --ink:        var(--bd1);
    --navy:       var(--bd1);
    --navy-soft:  var(--bd2);
    --text-muted: var(--bd2);
    --ink3:       var(--bd3);
    color: var(--bd1);
}

/*  The two bare field shells and the portal chrome declare their own type colours in
    scoped <style> blocks, against a chassis they know nothing about. */
[data-theme^="instrument"] .clock-back,
[data-theme^="instrument"] .clock-topbar-title,
[data-theme^="instrument"] .col-count { color: var(--bd1); }
[data-theme^="instrument"] .portal-foot-link { color: var(--bd2); }
/* ...but the ACTIVE category is a lit key, so it keeps the fill pair above. */
[data-theme^="instrument"] .ql-cat.active { color: var(--key-fill-ink); }

/*  --- INSTRUMENT wave 2, third pass: the last 42 -----------------------
    ⚠ HALF OF THESE WERE MY OWN OVER-CORRECTION, and they are worth recording because
    the mistake is the natural one: I grouped classes by the FILE they live in rather
    than by the SURFACE they sit on. /clock's back-link is on the chassis and its
    heading is on a bone card; QuickLog's hint is on the chassis and its category keys
    are on a card. Putting all of them on the bd tiers fixed the first of each pair and
    broke the second — 1.05:1 became 1.52:1, which is a different number and the same
    bug. The deciding question is never "which page is this" but "what does the nearest
    painted ancestor paint".                                                            */

/*  The two field shells DO have card-painted containers; they were simply not in the
    reset list, so everything inside them kept the chassis tier. */
[data-theme^="instrument"] .ql-card,
[data-theme^="instrument"] .clock-card {
    --ink:        var(--face-ink);
    --navy:       var(--face-ink);
    --navy-soft:  var(--face-ink2);
    --text-muted: var(--face-ink2);
    --ink3:       var(--face-ink3);
    --glyph-ui:   var(--face-ink2);
    color: var(--face-ink);
}

/*  ...so these go back to the face, and only the genuinely chassis-mounted ones —
    the back-link and the shell's own topbar title — keep the bd tier. */
[data-theme^="instrument"] .clock-head,
[data-theme^="instrument"] .clock-who,
[data-theme^="instrument"] .clock-week,
[data-theme^="instrument"] .ql-cat,
[data-theme^="instrument"] .ql-hint,
[data-theme^="instrument"] .fh-big-t { color: inherit; }
[data-theme^="instrument"] .clock-who,
[data-theme^="instrument"] .clock-week,
[data-theme^="instrument"] .ql-hint { color: var(--face-ink2); }
[data-theme^="instrument"] .ql-cat.active { color: var(--key-fill-ink); }

/*  A literal white inside a key fill. Fine while the key was always dark; at night the
    key is BONE, so white-on-bone is 1:1. It is one of the rgba literals the spec counted. */
[data-theme^="instrument"] .fh-big-cam .fh-big-s { color: var(--key-fill-ink); opacity: 0.78; }

/*  The kanban column headers paint TRANSLUCENT status tints, so on the chassis they are
    not a surface at all and their bone-face inks land on graphite. Given an opaque key
    they become real bays, and the stage keeps its hue using the chassis-safe variants —
    amber 5.75:1 and green 6.69:1 on the chassis, against 2.87 and 3.03 for the bone ones. */
[data-theme^="instrument"] .kanban-col-lead .kanban-col-header,
[data-theme^="instrument"] .kanban-col-estimate .kanban-col-header,
[data-theme^="instrument"] .kanban-col-approved .kanban-col-header,
[data-theme^="instrument"] .kanban-col-inprogress .kanban-col-header,
[data-theme^="instrument"] .kanban-col-walkthrough .kanban-col-header,
[data-theme^="instrument"] .kanban-col-paid .kanban-col-header {
    background: var(--key);
    color: var(--bd1);
}
[data-theme^="instrument"] .kanban-col-estimate .kanban-col-header    { color: var(--warn-ink); }
[data-theme^="instrument"] .kanban-col-approved .kanban-col-header,
[data-theme^="instrument"] .kanban-col-paid .kanban-col-header        { color: var(--ok-ink); }
[data-theme^="instrument"] .kanban-col-header .col-count              { color: var(--bd1); }

/*  A view-toggle button is transparent until it is the active one, so the inactive
    halves are chassis-mounted despite .btn owning its own ink. */
[data-theme^="instrument"] .view-toggle .btn:not(.active) { color: var(--bd2); }

/*  Flash and toast banners sit on the CANVAS, not in a card, so their status text needs
    the chassis-safe inks rather than the bone-face ones. This replaces the day rules
    written in wave 1, which assumed a face. */
[data-theme="instrument-day"] .flash-warn,
[data-theme="instrument-day"] .toast-warn { color: var(--warn-ink); border-color: var(--warn-ink); }
[data-theme="instrument-day"] .flash-ok,
[data-theme="instrument-day"] .toast-ok   { color: var(--ok-ink); border-color: var(--ok-ink); }
[data-theme="instrument-day"] .flash-bad,
[data-theme="instrument-day"] .toast-bad  { color: var(--bad-ink); border-color: var(--bad-ink); }
[data-theme="instrument-day"] .flash-info,
[data-theme="instrument-day"] .toast-info { color: var(--bd1); border-color: var(--bd3); }

/*  The intake assistant's bubble paints the BAY, which is housing. */
[data-theme^="instrument"] .it-assistant,
[data-theme^="instrument"] .it-notice-ai {
    --ink:        var(--bd1);
    --navy:       var(--bd1);
    --navy-soft:  var(--bd2);
    --text-muted: var(--bd2);
    color: var(--bd1);
}
[data-theme^="instrument"] .it-notice-ai   { color: var(--bd2); }
[data-theme^="instrument"] .portal-foot-who { color: var(--bd2); }

/*  A link inside a flash or a toast inherits the banner's status colour rather than the
    brand ink. Under Blueprint the two were both dark and the difference did not show;
    on the chassis the brand ink is #101314 and the banner is amber, so the link was the
    one unreadable word in a readable sentence. `inherit` also means the link can never
    drift from whichever status the banner is carrying. */
[data-theme^="instrument"] .flash a,
[data-theme^="instrument"] .toast a {
    color: inherit;
    text-decoration: underline;
    text-underline-offset: 2px;
}

/*  --- INSTRUMENT wave 2, fourth pass: the phone and tablet chrome -------
    Desktop reached 6 while phone and tablet sat at 262 and 258, because the mobile
    chrome is a different set of surfaces that the desktop walk never renders. 510 of
    those 520 were ONE class.

    ⚠⚠ THE MOBILE NAV IS FROSTED GLASS, AND THAT MAKES IT A CHASSIS SURFACE.
    `.mobilenav` declares a solid var(--card) and then immediately overrides it with
    color-mix(--card 32%, transparent). At 32% it is a thin bone film over whatever is
    behind it — which in Blueprint was a near-white canvas, and in Instrument is
    graphite. Dark text over that is genuinely unreadable rather than merely
    mis-measured: the composite really is 2.02:1 on a phone in daylight, which is the
    device this bar exists for. So under Instrument it frosts the BAY instead and takes
    the chassis tiers, which is also what it looks like it should be: a machined bar
    floating over the housing. */
[data-theme^="instrument"] .mobilenav {
    background: var(--bay);
    background: color-mix(in srgb, var(--bay) 86%, transparent);
    border-color: color-mix(in srgb, var(--bd3) 65%, transparent);
    --ink:        var(--bd1);
    --navy:       var(--bd1);
    --navy-soft:  var(--bd2);
    --text-muted: var(--bd2);
    --ink3:       var(--bd3);
    --glyph-ui:   var(--bd2);
    color: var(--bd1);
}
/*  ⚠ The camera circle stays a KEY fill and must not inherit the remap above — it
    paints var(--navy) as a background, which the chassis tier would turn bone-on-bone.
    It is already in the key-fill list; this re-asserts it after the remap. */
[data-theme^="instrument"] .mobilenav-camera-circle {
    background: var(--key-fill);
    color: var(--key-fill-ink);
}

/*  ⚠ A totals row moves between surfaces WITH THE BREAKPOINT, and that is the whole
    reason this rule is inside a media query. `.tbl-cards` is present at every width;
    it only becomes actual cards under 760px. Above that the tfoot sits inside the
    table's bone face and wants face ink; below it, the rows become cards and the tfoot
    falls out onto the chassis and wants bone. Writing it unguarded fixed the phone and
    broke the desktop by exactly the same amount — 1.05 became 1.52 — which is how a
    surface-dependent rule announces that it is missing its condition. */
@media (max-width: 760px) {
    [data-theme^="instrument"] .tbl-cards tfoot td,
    [data-theme^="instrument"] .tbl-cards tfoot th { color: var(--bd1); }
}

/*  The estimator's description glyph sits on the bone gutter, not the chassis. */
[data-theme^="instrument"] .desc-btn { color: var(--face-ink2); }

/*  The top-bar preview on /company paints var(--input-bg) — the bone gutter — inside a
    .bg-warm well that carries the chassis tiers, so its contents need the face back. */
[data-theme^="instrument"] .topbar-preview {
    --ink:        var(--face-ink);
    --navy:       var(--face-ink);
    --navy-soft:  var(--face-ink2);
    --text-muted: var(--face-ink2);
    color: var(--face-ink);
}

/* --- INSTRUMENT wave 3: type ------------------------------------------
   ⚠ Written as ONE combined selector rather than copied into each face, because none
   of it is a function of the face. Only colour is.  */
[data-theme="instrument-day"],
[data-theme="instrument-night"] {
    /*  'Archivo Fallback' sits directly after Archivo for the same reason 'Inter
        Fallback' does on :root: it is the metric-overridden face that renders during
        the swap window, and putting system-ui first restores the layout shift the
        override exists to prevent. Its four numbers are MEASURED against Archivo -
        Inter's do not transfer. */
    --font-sans:    'Archivo', 'Archivo Fallback', system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
    --font-mono:    'JetBrains Mono', ui-monospace, 'Cascadia Mono', Consolas, 'SF Mono', Menlo, monospace;
    /*  Oswald is condensed and uppercase-first: legends, not prose. It falls back to
        Archivo rather than to a system face so a missed load degrades to the body
        family instead of to Arial. */
    --font-display: 'Oswald', 'Archivo', 'Archivo Fallback', system-ui, sans-serif;

    /*  --- wave 4: geometry -------------------------------------------------
        Zero-radius, and zero means zero: instrument.html carries exactly ONE
        border-radius declaration in 37,964 bytes and it is `0`. These four tokens
        move 104 of the 149 radius declarations in this stylesheet. */
    --r-pill: 0;
    --r:      0;
    --r-sm:   0;
    --r-xs:   0;
}

/*  ⚠⚠ THE OTHER 45 ARE LITERALS AND CIRCLES, AND THIS IS WHY IT IS `!important`.
    After the four tokens go to zero the walk still reported 50% (avatars, stepper
    circles, the switch knob — 4,704 of them), plus 22px, 14px, 4px, 3px, 6px and 13px
    written straight into rules that predate the token scale. Chasing them one by one
    means finding all of them, and the audit's own count of "17 radii against 4 tokens"
    is the evidence that nobody ever does.

    `!important` is not a shortcut here, it is the accurate statement: in this theme a
    rounded corner is not a value to be overridden, it is a thing that does not exist.
    It is scoped to the Instrument faces, so Blueprint keeps every curve it has.

    ⚠ It squares the AVATARS too, and that is intended rather than collateral — the comp
    renders owner initials as square chips, which is what a machined key looks like. */
[data-theme="instrument-day"] *,
[data-theme="instrument-day"] *::before,
[data-theme="instrument-day"] *::after,
[data-theme="instrument-night"] *,
[data-theme="instrument-night"] *::before,
[data-theme="instrument-night"] *::after { border-radius: 0 !important; }

/*  ⚠⚠ TABULAR FIGURES ARE THE HALF OF "MONO ON NUMERICS" THAT ACTUALLY SCALES.
    There is no shared money component in this codebase - 482 call sites format money
    inline and the only helper is PDF-only - so re-typing every number at its call site
    was never the deliverable. This is: equal advance width on every figure in the
    product, so a column of money aligns on the decimal without a single markup change.
    JetBrains Mono then goes on the surfaces that are READOUTS rather than prose. */
[data-theme="instrument-day"] body,
[data-theme="instrument-night"] body { font-variant-numeric: tabular-nums; }

/*  Readouts. .mono already exists at 152 markup sites and keeps meaning what it meant;
    the rest are the surfaces a cluster would engrave: KPI values, money cells, record
    numbers, counts and the totals rows. */
[data-theme^="instrument"] .mono,
[data-theme^="instrument"] .num,
[data-theme^="instrument"] .kpi-val,
[data-theme^="instrument"] .kpi-box .kpi-val,
[data-theme^="instrument"] .bp-bigamt,
[data-theme^="instrument"] .bp-pval,
[data-theme^="instrument"] .bp-recno,
[data-theme^="instrument"] .col-count,
[data-theme^="instrument"] .inv-amt,
[data-theme^="instrument"] .co-amt,
[data-theme^="instrument"] .dash-col-val,
[data-theme^="instrument"] .dash-hl-count,
[data-theme^="instrument"] .dash-hbar-count,
[data-theme^="instrument"] .est-price b,
[data-theme^="instrument"] .clock-week,
[data-theme^="instrument"] table.tbl td.num,
[data-theme^="instrument"] table.tbl th.num {
    font-family: var(--font-mono);
    font-variant-numeric: tabular-nums;
    letter-spacing: -0.01em;
}

/*  Legends. The page title and the small uppercase labels are the silkscreen on the
    housing - they are what makes this read as a cluster rather than a document.
    ⚠ HELD TO THE EXISTING MICRO-LABEL FLOOR. Condensed uppercase letterspaced text is
    the least legible combination there is at small sizes, and app.css records that a
    phone sweep found 1,922 sub-12px text nodes on the device a crew reads outdoors.
    Oswald leans into exactly that pattern, so it may set the SHAPE and never the size:
    nothing here drops below --fs-2xs. */
[data-theme^="instrument"] .bp-projhead-title h1,
[data-theme^="instrument"] .bp-greet h1,
[data-theme^="instrument"] .auth-title,
[data-theme^="instrument"] .modal-title,
[data-theme^="instrument"] .action-bar-title,
[data-theme^="instrument"] .clock-head,
[data-theme^="instrument"] .askw-title,
[data-theme^="instrument"] h1 {
    font-family: var(--font-display);
    font-weight: 600;
    letter-spacing: 0.01em;
    text-transform: uppercase;
}
[data-theme^="instrument"] .section-header,
[data-theme^="instrument"] table.tbl thead th,
[data-theme^="instrument"] .kpi-label,
[data-theme^="instrument"] .lbl,
[data-theme^="instrument"] .field label {
    font-family: var(--font-display);
    font-weight: 500;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    font-size: max(var(--fs-2xs), 11px);
}

/*  ⚠ THE ONE FONT LEAK THE WALK FOUND, and it was hardcoded rather than tokenised.
    .topbar-logo names 'Inter' literally — a deliberate choice under Blueprint, where
    the comment records that a geometric sans matches the wordmark. Under Instrument
    the wordmark IS a legend, so it takes the display face; leaving it would have put
    two elements per page in a family the theme does not otherwise contain, which is
    exactly the "text rendered in Arial" class of defect the 08-15 audit drove to zero. */
[data-theme^="instrument"] .topbar-logo,
[data-theme^="instrument"] .topbar-logo-m {
    font-family: var(--font-display);
    letter-spacing: 0.02em;
}

/* --- The OVERRUN column ------------------------------------------------
   Signed days past the estimated end, drawn against a PRE-PRINTED ruler. The 2026-08-05
   design study's strongest idea, and all three of its finalists reached it separately.

   ⚠ WHY A BAR AND NOT A BADGE. On a real book most live jobs are past their estimate,
   so a red flag on each one makes a rash and the signal dies with it — every row urgent
   means none is. Length separates the four jobs that are genuinely in trouble from the
   eight that are a week late, and it does it before anyone reads a number.

   ⚠ THE LIMIT HAIRLINE IS DRAWN ON EVERY ROW, reached or not. That is the whole
   mechanism: two bars are comparable because they are measured against the same printed
   ruler rather than against each other. A scale that only spanned the data would make
   every screenful of jobs look equally bad.

   ⚠⚠ THE NUMBER IS INK, NEVER AMBER, ON A BONE FACE. #EF6C0B measures 2.66:1 there and
   fails AA at any size — the comp's own .dnum.hot is 12px bold amber on bone and is the
   study's signature element rendered illegible. The bar already carries the whole signal
   by crossing the line, so colouring the number was redundant emphasis that happened to
   be the unreadable part. Only the SEGMENT past the limit takes colour, and it carries a
   1px ink edge because amber on bone is under the 3:1 graphical-object bar too. */
.ovr-th { white-space: nowrap; }
.ovr-cell { width: 190px; }
.ovr { display: flex; align-items: center; gap: 8px; }

.ovr-num {
    flex: none;
    width: 38px;
    text-align: right;
    font-family: var(--font-mono);
    font-variant-numeric: tabular-nums;
    font-size: var(--fs-xs);
    letter-spacing: -0.02em;
    color: var(--text-muted);
}
.ovr-num.ovr-live   { color: var(--ink); font-weight: 700; }
.ovr-num.ovr-under  { color: var(--text-muted); }
.ovr-num.ovr-closed,
.ovr-num.ovr-none   { color: var(--ink3); font-weight: 400; }

.ovr-track {
    position: relative;
    flex: 1 1 auto;
    min-width: 90px;
    height: 14px;
}
/* the ruler's baseline */
.ovr-track::after {
    content: "";
    position: absolute;
    left: 0; right: 0; bottom: 2px;
    height: 1px;
    background: var(--line-mid);
}
.ovr-limit {
    position: absolute;
    bottom: 0;
    width: 1px;
    height: 11px;
    background: var(--line-mid);
}
.ovr-bar {
    position: absolute;
    left: 0; bottom: 3px;
    height: 8px;
    background: var(--ink3);
}
.ovr-over {
    position: absolute;
    bottom: 3px;
    height: 8px;
    background: var(--warn);
    box-shadow: inset 0 0 0 1px var(--ink);
}
.ovr-null {
    position: absolute;
    left: 0; bottom: 3px;
    width: 8px; height: 8px;
    border: 1px solid var(--line-mid);
}

/* The printed legend under the table. */
.ovr-scale {
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 8px 4px 2px;
    font-size: var(--fs-2xs);
    color: var(--ink3);
    text-transform: uppercase;
    letter-spacing: 0.06em;
}
.ovr-scale b { font-weight: 600; color: var(--text-muted); }

/*  ⚠ On a phone the table becomes cards and the ruler loses its column, so the bar goes
    full width under its label rather than being squeezed into a 38px gutter. */
@media (max-width: 760px) {
    .ovr-cell { width: auto; }
    .ovr-track { min-width: 120px; }
}

/*  ⚠ THE LEGEND CHANGES SURFACE WITH THE BREAKPOINT, exactly like the AR-aging totals
    row did. Above 760px it sits inside the table's bone face and takes face ink; below,
    the table becomes cards and the legend falls out onto the chassis, where the same ink
    measures 2.87:1. Caught by the walk on the phone viewport only — a desktop-only run
    would have shipped it. Second instance of this shape in one session, which is enough
    to call it a rule: anything rendered OUTSIDE the rows of a .tbl-cards table is
    surface-dependent and needs the guard. */
@media (max-width: 760px) {
    [data-theme^="instrument"] .ovr-scale   { color: var(--bd2); }
    [data-theme^="instrument"] .ovr-scale b { color: var(--bd1); }
}
