@font-face{font-family:'Inter';src:url(/static/fonts/Inter-Regular.woff2) format('woff2');font-weight:400;font-display:swap;}
@font-face{font-family:'Inter';src:url(/static/fonts/Inter-Medium.woff2) format('woff2');font-weight:500;font-display:swap;}
@font-face{font-family:'Inter';src:url(/static/fonts/Inter-SemiBold.woff2) format('woff2');font-weight:600;font-display:swap;}
@font-face{font-family:'Inter';src:url(/static/fonts/Inter-Bold.woff2) format('woff2');font-weight:700 800;font-display:swap;}

/* =====================================================================
   Playpro OS — shared design system
   Tokens, shell and reusable components. Module templates should lean on
   the classes defined here rather than re-declaring them.
   Brand (D12): Inter · #00AFEF primary · #008CCD deep · #001F33 navy.
   RULE: #00AFEF is a FILL/BAND colour only — NEVER text (--brand-ink is the
   AA-safe text blue). Light-first; dark (navy-derived) via [data-theme].

   THE SCALES (below). If you are about to write a literal px value for a
   radius, a z-index, a gap or an uppercase micro-label size, stop: it is
   already a token. A literal that is not on a scale is how this file grew
   five "panel corner" radii, seven letter-spacings and 21 z-index values.

   The legacy chassis var names (--sage/--coral/...) are GONE. Every template
   reference has been swept onto the real tokens: sage (calm/positive) ->
   the brand blues, coral (attention) -> amber. There is one palette.
   ===================================================================== */

/* ---------------------------------------------------------------------
   SCALE TOKENS — theme-independent geometry and layering.
   --------------------------------------------------------------------- */
:root{
  /* SPACING — read this before reaching for a token that does not exist.
     A `--s1..--s6` 4px scale was declared here and documented as "the"
     spacing scale. It had ZERO usages — 0 in this file, 0 across all 87
     templates — while the literals actually written here are, by frequency,
     10 / 14 / 16 / 12 / 8 / 7 / 6 / 2 / 4 / 18px. The dominant ones are all
     OFF a 4px scale, so the comment was actively misleading: a reader who
     trusted it and reached for --s3 got 12px where the surrounding code
     means 14px, re-introducing the very drift the comment warned about.
     It is deleted rather than adopted, because the value of a spacing token
     is ENFORCEMENT and there is no gate that can enforce a numeric scale.

     Spacing is NOT on a fixed scale. It is per-component, and that is fine.
     What IS load-bearing are these three numbers, each of which has already
     caused a shipped bug by drifting against its twin — they must move
     together, so they are the only spacing tokens that exist: */
  --gutter:14px;    /* the grid gutter. .cardgrid/.tiles/.grid2/.drow all share
                       it; .dwrap vs .dgrid once disagreed 14 vs 15px. */
  --stack:16px;     /* flow gap between sibling cards (.card + .card) and
                       between a card and an adjacent card GRID. */
  --pad-card:20px;  /* inner padding of .card AND .tile. These two MUST stay
                       identical: when they differed by 2-4px the inner text
                       edge visibly shifted between a tile row and the cards
                       under it. */

  /* TYPE SCALE — px values, deliberately NOT rem. See the note at the top of
     the type section below for why the root-relative flip is not done yet.
     The point of this layer is that there is now exactly ONE place to change
     a size, which is the prerequisite for that flip ever being safe. */
  --fs-1:9px;     /* micro: .etag.sm, crew initials                        */
  --fs-2:10.5px;  /* uppercase micro-label: .label-micro, .data-table th   */
  --fs-3:11px;    /* section label + chip: .label-sect, .pill, .tag        */
  --fs-4:11.5px;  /* sub / meta: .sub, .cmeta, .footnote, .chead a         */
  --fs-5:12.5px;  /* dense UI: .tab, .flash, .error, .filterbar, .page-sub */
  --fs-6:13px;    /* control text: .btn, .data-table td, .kv, .toast       */
  --fs-7:13.5px;  /* body + inputs, .card-title, .card h2, .msg            */
  --fs-8:14px;    /* mid headings                                          */
  --fs-9:15px;    /* lead: .empty .em-lead                                 */
  --fs-10:19px;   /* secondary stat: .tile.dense .num                      */
  --fs-11:23px;   /* .page-head h1                                         */
  --fs-12:26px;   /* .tile .num / .kpi / .wordmark                         */

  /* corner radius — THREE values, no more.
       --radius     cards, panels, dialogs, drawers
       --radius-sm  buttons, inputs, chips, banners, small tiles
       --radius-xs  inline glyph tiles, swatches, dense chips
     99px (a full pill) is the only legitimate literal. */
  --radius:13px;--radius-sm:9px;--radius-xs:6px;

  /* layering scale — never write a bare z-index again, pick a step.
       --z-sticky      sticky topbar, sticky table heads
       --z-menu        popovers, kanban card menus, combobox lists
       --z-docked      docked surfaces: touch bar, slide-over veil
       --z-docked-top  the docked panel itself (rides its own veil)
       --z-fab         floating pill (feedback): above sticky, BELOW docked,
                       so it can never cover a docked surface
       --z-banner      impersonation banner (owns the top edge)
       --z-overlay     modal veils: move dialog, schedule veil, lightbox
       --z-drag        pointer-attached drag ghost (schedule bar). ABOVE the
                       modal veil deliberately: a ghost is glued to the cursor,
                       so if the two ever do coexist the ghost must not be
                       buried under a veil the user cannot see it through.
                       Below the toast stack, which always outranks everything
                       transient. This was a bare `z-index:400` literal in
                       _schedule_bar.html — the ONE position:fixed element in
                       the app that had joined the root stacking context with
                       an undeclared magic number.
       --z-toast       toast stack
       --z-confirm     branded confirm() dialog — always the last word
       --z-skip        skip-to-content link when focused */
  --z-sticky:30;--z-menu:40;--z-docked:60;--z-docked-top:61;--z-fab:70;
  --z-banner:200;--z-overlay:300;--z-drag:310;--z-toast:1000;--z-confirm:1200;--z-skip:1300;

  /* Height of the impersonation banner when one is present. The banner, the
     topbar and the sidebar all stick to top:0 in the SAME scroll root, so the
     other two offset FROM this token instead of guessing. See .imp-banner. */
  --banner-h:0px;
}

:root,:root[data-theme="light"]{
  color-scheme:light;
  --ground:#F9FAFB;--panel:#fff;--panel2:#F1F5F8;--line:#E2E8EE;--line2:#CBD6DE;
  --ink:#001F33;--ink2:#334155;--ink3:#616F86;
  --brand:#00AFEF;--brand-deep:#008CCD;--brand-ink:#00719C;--brand-tint:#E5F6FD;--brand-tint2:#CFEEFB;
  --navy:#001F33;--accent:#6EC1E4;--on-brand:#001F33;
  /* solid high-weight chip/indicator fill. Unlike --navy this is THEME-AWARE,
     so navy-on-panel components stay visible when the panel goes dark. */
  --pill-strong:#001F33;--on-pill-strong:#fff;
  --danger:#9E4239;--danger-tint:#F9EBE9;
  --amber:#C88A2E;--amber-tint:#F7EDDA;--amber-line:#E6CFA0;--amber-ink:#8A5A1E;
  /* ink ON a solid band of the fixed colours. Both bands are theme-static
     (the amber band lightens, the navy chip never does), so their ink is a
     fixed pair too — this was hard-coded in four places. */
  --on-amber:#402A08;--on-navy:#fff;
  /* SIGNATURE PAD — theme-STATIC, like --navy. signature.js pins the ink to
     #001F33 because the signature is evidence and every artefact it lands on
     (form_pdf, form_print, handover_cert, handover_pdf) is printed on white.
     The pad's backdrop was var(--panel2), which is #0A2E47 in dark — navy ink
     on navy paper measured 1.21:1, so a crew member signing in dark mode
     could not see the stroke they were drawing. Paper is white in BOTH
     palettes so what is on screen is what is on the document. */
  --sig-paper:#fff;--sig-edge:#CBD6DE;--sig-ink:#001F33;
  /* settled/closed accent. pipeline_ui.STATE_HUE asks for var(--ok,#2E7D4F);
     the token was never declared, so the board's "Paid" rule fell back to a
     hardcoded light-theme green that went muddy on the dark board. Declared
     here (light value identical to the old fallback) so both themes resolve. */
  --ok:#2E7D4F;
  --shadow:0 1px 2px rgba(0,31,51,.05),0 8px 24px -12px rgba(0,31,51,.12);
  --shadow-lift:0 2px 4px rgba(0,31,51,.06),0 18px 40px -18px rgba(0,31,51,.20);
  /* elevation for anything that floats over the page (toast, dialog,
     slide-over, menu). Four bespoke overlay shadows used to exist. */
  --shadow-overlay:0 18px 48px -12px rgba(0,31,51,.5);
}
:root[data-theme="dark"]{
  color-scheme:dark;
  --ground:#00131F;--panel:#04253B;--panel2:#0A2E47;--line:#123A55;--line2:#1D4A66;
  --ink:#E7F2F9;--ink2:#A9C2D4;--ink3:#7C9DB3;
  --brand:#22B8F0;--brand-deep:#5BC9F4;--brand-ink:#7FD2F7;--brand-tint:#0A3A55;--brand-tint2:#0F4666;
  --navy:#001F33;--accent:#6EC1E4;--on-brand:#001F33;
  --pill-strong:#E7F2F9;--on-pill-strong:#001F33;
  --danger:#D98A82;--danger-tint:#392724;
  --amber:#D6A75F;--amber-tint:#352B1C;--amber-line:#4A3D28;--amber-ink:#D8B06A;
  --on-amber:#402A08;--on-navy:#fff;
  /* SIGNATURE PAD — theme-STATIC, like --navy. signature.js pins the ink to
     #001F33 because the signature is evidence and every artefact it lands on
     (form_pdf, form_print, handover_cert, handover_pdf) is printed on white.
     The pad's backdrop was var(--panel2), which is #0A2E47 in dark — navy ink
     on navy paper measured 1.21:1, so a crew member signing in dark mode
     could not see the stroke they were drawing. Paper is white in BOTH
     palettes so what is on screen is what is on the document. */
  --sig-paper:#fff;--sig-edge:#CBD6DE;--sig-ink:#001F33;
  --ok:#5FB98A;
  --shadow:0 1px 2px rgba(0,0,0,.3),0 8px 24px -12px rgba(0,0,0,.5);
  --shadow-lift:0 2px 4px rgba(0,0,0,.35),0 18px 40px -18px rgba(0,0,0,.6);
  --shadow-overlay:0 18px 48px -12px rgba(0,0,0,.72);
}
/* dark is opt-in via the toggle only — platform defaults to light */
@media (prefers-color-scheme:dark){
  :root[data-theme="dark"]{
  color-scheme:dark;
  --ground:#00131F;--panel:#04253B;--panel2:#0A2E47;--line:#123A55;--line2:#1D4A66;
  --ink:#E7F2F9;--ink2:#A9C2D4;--ink3:#7C9DB3;
  --brand:#22B8F0;--brand-deep:#5BC9F4;--brand-ink:#7FD2F7;--brand-tint:#0A3A55;--brand-tint2:#0F4666;
  --navy:#001F33;--accent:#6EC1E4;--on-brand:#001F33;
  --pill-strong:#E7F2F9;--on-pill-strong:#001F33;
  --danger:#D98A82;--danger-tint:#392724;
  --amber:#D6A75F;--amber-tint:#352B1C;--amber-line:#4A3D28;--amber-ink:#D8B06A;
  --on-amber:#402A08;--on-navy:#fff;
  /* SIGNATURE PAD — theme-STATIC, like --navy. signature.js pins the ink to
     #001F33 because the signature is evidence and every artefact it lands on
     (form_pdf, form_print, handover_cert, handover_pdf) is printed on white.
     The pad's backdrop was var(--panel2), which is #0A2E47 in dark — navy ink
     on navy paper measured 1.21:1, so a crew member signing in dark mode
     could not see the stroke they were drawing. Paper is white in BOTH
     palettes so what is on screen is what is on the document. */
  --sig-paper:#fff;--sig-edge:#CBD6DE;--sig-ink:#001F33;
  --ok:#5FB98A;
  --shadow:0 1px 2px rgba(0,0,0,.3),0 8px 24px -12px rgba(0,0,0,.5);
  --shadow-lift:0 2px 4px rgba(0,0,0,.35),0 18px 40px -18px rgba(0,0,0,.6);
  --shadow-overlay:0 18px 48px -12px rgba(0,0,0,.72);
  }
}

*{box-sizing:border-box;margin:0;}
html{color-scheme:light dark;}
body{background:var(--ground);color:var(--ink);font-family:'Inter',system-ui,sans-serif;font-size:var(--fs-7);line-height:1.5;-webkit-font-smoothing:antialiased;}
a{color:var(--brand-ink);}
h1,h2,h3,h4{line-height:1.2;}

/* ---- brand type ---- */
/* Wordmark is normally the raster logo (D12: Neon80s display face stays in
   the logo asset); this text fallback is Inter caps in ink. */
.brand-script,.wordmark{font-family:'Inter',system-ui,sans-serif;color:var(--ink);font-weight:800;letter-spacing:.12em;text-transform:uppercase;}

/* =====================================================================
   TYPE SCALE — WHY THESE TOKENS ARE STILL px, AND WHAT UNBLOCKS rem.

   Every font-size in THIS FILE now reads a --fs-* token (79 declarations,
   22 distinct px values folded to 12 tokens; max shift ±0.5px). That is
   deliberately the whole job for this pass. The root-relative flip —
   `html{font-size:62.5%}` plus changing the 12 token VALUES to rem, a
   ~13-line diff — is NOT done, and must not be, until the templates follow.

   The blocker is arithmetic, not caution. This stylesheet holds ~88 of the
   platform's ~690 font-size declarations (13%). The other ~600 live in
   template <style> blocks (~483) and inline style= attributes (~120).
   Flipping the root today would scale the 13% that reads a token and leave
   the 87% pinned — so a user who raises their browser font size would get a
   UI that pulls itself apart, component by component, rather than one that
   grows. That is measurably WORSE than today's uniform non-scaling, which is
   exactly why this item has been deferred three times. The reason is sound.

   ORDER OF OPERATIONS to actually land rem (each step safe on its own):
     1. DONE — this file on tokens, one place to change a size.
     2. Retire the ~120 inline `style="font-size:…"` attributes; most map onto
        classes that ALREADY EXIST (.label-micro, .label-sect, .page-sub,
        .cmeta/.sub/.footnote — see the inline-style note in the gate).
     3. Repoint the ~483 template <style> font-sizes at these same tokens.
     4. Gate it: verify_ui.py::check_type_scale already fails any NEW px
        font-size in playpro.css, so this file cannot regress while 2-3 run.
        Widen it to app/templates/** once the count is near zero.
     5. ONLY THEN flip the 12 token values to rem. One edit, whole platform,
        atomically. The 62.5% trick is safe HERE specifically because Inter is
        self-hosted (no third-party CSS), no media query uses rem, and there
        is no existing rem consumer to mis-resolve — all three verified.
   EXCLUDED from all of the above, permanently: app/templates/pdf/* and
   form_print/form_pdf/pack_cover/handover_cert (pt units, physical page
   geometry) and email/digest.html (email clients do not support custom
   properties at all — its inline styles are correct as written).
   =====================================================================

   THE MICRO-LABEL — the uppercase micro-label. Exactly TWO.
   One visual style used to carry ~38 physical declarations across three
   font-sizes, seven letter-spacings, two inks and two weights. Everything
   that WAS one of these is folded into the selector lists below, so no
   template has to change to inherit the token; new markup should just use
   the class name.
     .label-sect   section heading inside a card    11px   / 700 / .1em  / ink2
     .label-micro  column head + field micro-label  10.5px / 700 / .08em / ink3
   .ngroup (sidebar group, 10px/.14em) is the ONE deliberate exception — it
   labels the chrome, not the content.
   ===================================================================== */
.label-sect,
label,
.sect,
.chead h2,
.cardgrid .wchead h2,
fieldset.fgroup > legend{
  font-size:var(--fs-3);font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--ink2);}
.label-micro,
.kv .k,.kv > dt,
.data-table th,
.drow.head,
.tile .lbl{
  font-size:var(--fs-2);font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--ink3);}

/* muted secondary line under a title or a number. ~20 page-local selectors
   (.acct-name .sub, .job-title-sub, .q-sub, .r-sub, .site-name .sub, .rg-meta,
   .tnote, …) all mean exactly this and differed only by a 1-4px margin-top.
   This is what .muted should have been. */
.sub,.tile .sub{color:var(--ink3);font-size:var(--fs-4);margin-top:2px;line-height:1.45;}

/* ---- generic helpers ---- */
.muted{color:var(--ink3);}
.num{font-variant-numeric:tabular-nums;}
.right{text-align:right;}
.nowrap{white-space:nowrap;}
hr.split,.split{border:none;border-top:1px solid var(--line);margin:18px 0;}
/* [hidden] must beat any author `display` rule (e.g. `.btn{display:inline-flex}`),
   otherwise `el.hidden = true` silently does nothing. Declared once, globally. */
[hidden]{display:none!important;}
/* screen-reader-only live region / label */
.visually-hidden{position:absolute!important;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;border:0;}

/* ============================ buttons ============================ */
.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;background:var(--brand);color:var(--on-brand);font-weight:700;font-size:var(--fs-6);border:1px solid transparent;border-radius:var(--radius-sm);padding:9px 18px;cursor:pointer;font-family:inherit;text-decoration:none;line-height:1.2;transition:filter .12s ease,background .12s ease,border-color .12s ease;}
.btn:hover{filter:brightness(.96);}
.btn:focus-visible{outline:2px solid var(--brand-deep);outline-offset:2px;}
/* disabled: must never look or feel live. `.btn` sets its own background AND
   colour, so the UA disabled appearance is fully overridden — restate it here. */
.btn:disabled,.btn[disabled],.btn.is-disabled{opacity:.5;cursor:not-allowed;filter:none;box-shadow:none;}
.btn:disabled:hover,.btn[disabled]:hover,.btn.is-disabled:hover{filter:none;}
/* in-flight submit state (see the submit guard in base.html). Applies to any
   submit control, including the tablet touch-bar actions. */
button[aria-busy="true"],input[aria-busy="true"]{opacity:.6;cursor:progress;pointer-events:none;}
/* why a control is disabled — title tooltips do not exist on the crew
   tablets, so the reason has to be rendered. */
.btn-why{display:inline-block;font-size:var(--fs-4);font-weight:600;color:var(--amber-ink);background:var(--amber-tint);border:1px solid var(--amber-line);border-radius:99px;padding:3px 10px;align-self:center;}
.btn svg{width:15px;height:15px;stroke:currentColor;stroke-width:1.9;fill:none;stroke-linecap:round;stroke-linejoin:round;}
.btn.small{padding:7px 13px;font-size:var(--fs-5);}
.btn.tiny{padding:5px 12px;font-size:var(--fs-3);border-radius:99px;}
.btn.block{width:100%;}
/* secondary / ghost — no colour weight, for "not the primary action" */
.btn.ghost,.btn.quiet{background:var(--panel2);color:var(--ink2);border:1px solid var(--line);font-weight:600;}
.btn.ghost:hover,.btn.quiet:hover{background:var(--panel);border-color:var(--line2);filter:none;}
.btn.outline{background:transparent;color:var(--brand-ink);border:1px dashed var(--line2);font-weight:600;}
.btn.outline:hover{border-color:var(--brand);filter:none;}
/* danger / destructive — amber is legitimate attention here */
.btn.danger{background:var(--amber-tint);color:var(--amber-ink);border:1px solid var(--amber);}
.btn.danger:hover{background:var(--amber);color:var(--on-amber);filter:none;}

/* ============================ forms ============================ */
input,select,textarea{font-family:inherit;font-size:var(--fs-7);border:1px solid var(--line2);border-radius:var(--radius-sm);padding:9px 12px;width:100%;background:var(--panel);color:var(--ink);}
/* Selects: one custom chevron, tracking --ink3 in BOTH themes. The old
   data-URI hard-coded %23848E86 — a warm green-grey inherited from the
   retired sage chassis, matching no Playpro token. */
select{appearance:none;-webkit-appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%23616F86' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 12px center;padding-right:32px;}
:root[data-theme="dark"] select{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%237C9DB3' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");}
select::-ms-expand{display:none;}
/* checkboxes & radios: brand accent instead of the browser default blue */
input[type=checkbox],input[type=radio]{width:auto;accent-color:var(--brand-deep);}
/* focus ring: --brand (#00AFEF) is only 2.51:1 on white — below the 3:1 WCAG
   1.4.11 floor for a focus indicator. --brand-deep is 3.73:1 on panel. */
input:focus,select:focus,textarea:focus{outline:2px solid var(--brand-deep);outline-offset:0;border-color:var(--brand-deep);}
input::placeholder,textarea::placeholder{color:var(--ink3);}
label{display:block;margin:14px 0 5px;}
/* margin:0 is load-bearing. This rule overrode everything about the base
   `label` EXCEPT its `margin:14px 0 5px`, so every bare .checkline silently
   inherited a 14px top margin — and seven templates hand-compensated with
   inline margin-top:34/26/10px nudges. Containers own checkbox-row spacing
   via their own gap. */
label.inline,.checkline{margin:0;font-size:var(--fs-5);font-weight:500;letter-spacing:0;text-transform:none;color:var(--ink);display:flex;align-items:center;gap:8px;cursor:pointer;}
/* a group of controls (e.g. a checkbox set) with a heading — legend gives it an
   accessible name; styled to read exactly like a field <label>. */
fieldset.fgroup{border:none;padding:0;margin:0;min-inline-size:0;}
fieldset.fgroup > legend{padding:0;margin:14px 0 5px;}
label .opt{font-weight:500;text-transform:none;letter-spacing:0;color:var(--ink3);}
/* two-column form body.
   VERTICAL RHYTHM IS THE GRID'S ROW-GAP — never a child index. This used to be
   `gap:2px 16px` with `:nth-child(-n+2) label{margin-top:0}`, which hardcodes
   "every .form-grid is exactly two columns and no .col-2 ever leads". Both
   assumptions are false in shipped code (rates_admin overrides to a 6-column
   auto-fit; job_new and account_detail lead with .col-2), so first-row labels
   sat flush while their row-mates were pushed 14px down. */
.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px 16px;align-items:start;}
.form-grid .col-2{grid-column:1 / -1;}
.form-grid > *{min-width:0;}
.form-grid > * > label:first-child,.form-grid > label,
.form-grid > * > fieldset.fgroup:first-child > legend{margin-top:0;}
.field label{margin-top:14px;}
.form-actions{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:20px;}
.inline-form{display:flex;gap:8px;align-items:flex-end;flex-wrap:wrap;}
.inline-form > div{flex:1;min-width:160px;}
.inline-form select,.inline-form input{width:100%;}
.inline-form label{margin-top:0;}
@media (max-width:640px){.form-grid{grid-template-columns:1fr;}}

/* filter/search bar above a register. Nine pages declared this locally in four
   variants that differed ONLY by margin-bottom (10 / 12 / 14px), so the gap
   between the filters and the table below changed page to page. One rule,
   matching the 18px `.tabs` rhythm above it. */
.filterbar{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:14px;}
.filterbar label{margin:0;}
.filterbar input[type=search]{max-width:260px;}
/* `width:auto` on a <select> means "as wide as the widest <option>", and an
   account picker's options are CLIENT NAMES. /maintenance's Client filter
   measured 694px in a 390px viewport (334 accounts, longest name 92ch) and
   pushed the whole page sideways — the filter bar wraps, but a 694px flex
   item still wraps to a 694px LINE. A <select> also refuses to shrink below
   its min-content width, so max-width alone is not enough: min-width:0 is
   what actually lets it back inside the viewport. min-width:130px is kept as
   a floor on wide screens via the min() so short filters (Status/Priority)
   still read as controls rather than collapsing to the chevron. */
.filterbar select{width:auto;min-width:0;max-width:100%;}
@media (min-width:560px){.filterbar select{min-width:130px;}}
.filterbar .checkline{white-space:nowrap;}
/* stacked visible label above the control (maint_home's filter model) */
.filterbar.stacked{align-items:end;}
.filterbar.stacked > label{display:flex;flex-direction:column;gap:3px;min-width:0;max-width:100%;}

/* ---- .compact — dense control sizing, ONE declaration ----
   Six pages shrank their filter/edit controls with a `padding` SHORTHAND,
   which resets the 32px right gutter the custom <select> chevron is painted
   into — so the chevron sat on top of the longest option's text (worst
   exactly where the data is longest: a width:auto client filter). Any wrapper
   that wants smaller controls takes this class instead of writing padding. */
.compact input,.compact select,.compact textarea{font-size:var(--fs-5);padding:7px 9px;}
.compact select{padding-right:30px;background-position:right 9px center;}
.compact label{margin-top:0;}

/* ============================ cards ============================ */
.card{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:var(--pad-card);min-width:0;}
/* Cards stacked in NORMAL FLOW space themselves. Inside a grid/flex container
   the container's `gap` already does it, and this margin pushes every card
   after the first 16px down the page — the ragged card tops the operator
   reported. CSS cannot select "is a grid item", so every card-holding layout
   container in the app is reset here, in ONE place.
   ADDING A NEW CARD GRID? Add its class to this list (or just use .cardgrid). */
.card + .card{margin-top:var(--stack);}
.cardgrid > .card + .card,
.grid2 > .card + .card,
.tiles > .card + .card,           /* KPI tile strip                 */
.comp-tiles > .card + .card,      /* company_dashboard compliance   */
.secgrid > .card + .card,         /* credentials_list               */
.detgrid > .card + .card,         /* asset_detail                   */
.wogrid > .card + .card,          /* maint_wo_detail                */
.slacols > .card + .card,         /* maint_sla                      */
.lane-grid > .card + .card,       /* settings_crews                 */
.ent-grid > .card + .card,        /* settings_entities              */
.jf-grid > .card + .card,         /* files_home job files           */
.toolgrid > .card + .card,        /* files_home tools               */
.rgrid > .card + .card,           /* receipts_inbox                 */
.vstrip > .card + .card,          /* quotes_list, quote_register    */
.kb-cards > .card + .card,        /* pipeline_board columns         */
/* `.sched-layout > .card + .card` was here. It was the schedule's board-column
   + 300px-rail grid; the rail is retired and the board no longer stacks cards,
   so the entry had no remaining consumer and verify_ui.py's dead-css check
   flags it. Removed with the layout that owned it. */
.errpage > .card + .card{margin-top:0;}
/* a card GRID stacked under/over a card is a card sibling for rhythm purposes —
   ten call sites were overriding .card + .card with inline margin-top:14/16px. */
.detgrid + .detgrid,.card + .detgrid,.detgrid + .card,
.secgrid + .card,.card + .secgrid{margin-top:var(--stack);}
.card.flush{padding:8px var(--pad-card);}
.card.pad0{padding:0;}
.card.tint{background:linear-gradient(140deg,var(--brand-tint),var(--panel));}
.card.hint{background:var(--panel2);border-style:dashed;box-shadow:none;}
/* Card headings settle on TWO: .card-title (13.5px sentence case) and
   .chead h2 (the .label-sect token). Six h2 sizes used to exist. */
.card-title{font-weight:700;font-size:var(--fs-7);margin-bottom:4px;color:var(--ink);}
/* A BARE <h2> in a card had no declared size at all, so it fell through to the
   UA default 1.5em = 20.25px bold — nearly the weight of the 23px page <h1>
   above it, and the same structural slot then shrank 20px → 15px → 11px moving
   Maintenance → Assets → Settings. It lands on .card-title's metrics.
   :where() keeps this at ZERO added specificity so the uppercase variants
   (.chead h2, .wchead h2 via the .label-sect token) still win. */
.card :where(h2){font-size:var(--fs-7);font-weight:700;line-height:1.3;letter-spacing:0;text-transform:none;color:var(--ink);margin:0 0 10px;}
.sect{margin:0 0 10px;}
.sect + .sect,.card > .sect ~ .sect{margin-top:18px;}
.chead{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;gap:8px;
  /* several headers pair a long title with right-hand meta ("Accounts
     receivable — Xero" + "N open invoices · M clients"); without wrapping they
     collide or push out of the card below ~700px of card width. */
  flex-wrap:wrap;row-gap:4px;}
.chead > h2{min-width:0;}
.chead a{font-size:var(--fs-4);color:var(--brand-ink);font-weight:600;text-decoration:none;}
.chead a:hover{text-decoration:underline;}
/* right-hand meta line in a card / section header — was eight repeated
   `style="font-size:var(--fs-4)"` muted spans across four templates. */
.cmeta{font-size:var(--fs-4);color:var(--ink3);font-weight:500;}
.chead .cmeta,.cardgrid .wchead .cmeta{margin-left:auto;}

/* ============================ page shell (base.html) ============================ */
.navtoggle{display:none;}
.appframe{display:grid;grid-template-columns:222px minmax(0,1fr);min-height:100vh;background:var(--ground);}
.appside{background:var(--panel2);border-right:1px solid var(--line);padding:12px 12px 10px;display:flex;flex-direction:column;gap:2px;position:sticky;top:var(--banner-h);height:calc(100vh - var(--banner-h));overflow:hidden;}
.logoplate{padding:6px 10px 5px;margin:0 4px 6px;display:flex;justify-content:flex-start;text-decoration:none;}
.wordmark{font-size:var(--fs-12);line-height:1.15;}
.logoplate img{width:114px;height:auto;display:block;}
/* The wordmark is a single RASTER asset, so it cannot recolour with the theme.
   The dark sidebar (--panel2 = #0A2E47) swallowed the dark half of the mark,
   so the reversed asset is swapped in on the theme attribute instead.
   playpro-logo-white.png was already in the repo but was referenced ONLY by
   the PDF theme (app/pdf_theme.py, pdf/base_pdf.html); the running app never
   loaded it. Both are now shipped and swapped here.
   BRAND ASSETS: both PNGs are byte-identical to the masters at
   /mnt/nas/mirai/Projects/playpro/brand/logo/ ("Playpro Logo RGB.png" and
   "Playpro Logo White.png") — 781x181 / 781x180 RGBA, the monogram + lowercase
   "playpro" lockup. They REPLACED a 848x260 all-caps "PLAYPRO / PLAYGROUNDS ·
   LANDSCAPES · SHADE" placeholder that was never a real Playpro asset, so the
   aspect ratio changed 3.26:1 -> 4.31:1. Anything that sizes the mark on ONE
   axis (this rule's width:114px, .login-logo's width:210px, and .pdf-band
   .logo img's height:44px) now yields a different box on the other axis —
   check those three if the asset is ever swapped again.
   NOTE for the brand call: the white asset is a FLAT reversed lockup, so dark
   mode loses the cyan "play". If that matters, request a two-tone reversed
   asset (cyan play + white pro) — this CSS is unchanged either way. */
.logoplate .logo-dark{display:none;}
:root[data-theme="dark"] .logoplate .logo-light{display:none;}
:root[data-theme="dark"] .logoplate .logo-dark{display:block;}
.ngroup{font-size:var(--fs-2);font-weight:700;letter-spacing:.14em;text-transform:uppercase;color:var(--ink3);padding:7px 10px 2px;}
/* main nav scrolls independently; Settings / My profile stay pinned in .sidefoot.
   The sticky ::after paints a bottom fade only while there is more nav to scroll. */
.navscroll{flex:1 1 auto;min-height:0;overflow-y:auto;overscroll-behavior:contain;}
.navscroll::after{content:"";position:sticky;bottom:0;display:block;height:20px;margin-top:-20px;pointer-events:none;background:linear-gradient(to bottom,rgba(0,0,0,0),var(--panel2));}
.appnav{display:flex;flex-direction:column;gap:2px;}
.appnav a,.appnav .navbtn{display:flex;align-items:center;gap:9px;padding:5px 10px;border-radius:var(--radius-sm);color:var(--ink2);text-decoration:none;font-weight:500;font-size:var(--fs-6);font-family:inherit;background:none;border:none;width:100%;cursor:pointer;text-align:left;line-height:1.35;transition:background .12s ease,color .12s ease;}
.appnav a:hover,.appnav .navbtn:hover{background:var(--brand-tint);color:var(--brand-ink);}
/* --brand is 2.29:1 on the --panel2 sidebar — below the 3:1 WCAG 1.4.11 floor
   for a focus indicator. --brand-deep is 3.40:1 there. Same call as input:focus. */
.appnav a:focus-visible,.appnav .navbtn:focus-visible{outline:2px solid var(--brand-deep);outline-offset:1px;}
.appnav a.on{background:var(--brand);color:var(--on-brand);font-weight:600;}
.appnav a.on svg{stroke:var(--on-brand);}
.appnav svg{width:15px;height:15px;stroke:var(--ink3);stroke-width:1.8;fill:none;stroke-linecap:round;stroke-linejoin:round;flex:none;}
.appnav a:hover svg,.appnav .navbtn:hover svg{stroke:var(--brand-ink);}
.appnav .dot{margin-left:auto;background:var(--amber);color:var(--on-amber);font-size:var(--fs-2);font-weight:700;border-radius:99px;padding:1px 7px;}
.sidefoot{margin-top:auto;padding-top:10px;border-top:1px solid var(--line);}

.appmain{display:flex;flex-direction:column;min-width:0;}
.apptop{display:flex;align-items:center;gap:12px;padding:11px 22px;border-bottom:1px solid var(--line);background:var(--panel);position:sticky;top:var(--banner-h);z-index:var(--z-sticky);}
.pagetitle{font-size:var(--fs-6);font-weight:700;color:var(--ink);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.scope{display:inline-flex;align-items:center;gap:7px;background:var(--brand-tint);color:var(--brand-ink);font-weight:600;font-size:var(--fs-5);padding:5px 12px;border-radius:99px;border:1px solid var(--brand-tint2);white-space:nowrap;}
.scope svg{width:13px;height:13px;stroke:currentColor;stroke-width:2;fill:none;flex:none;}
.tb-right{margin-left:auto;display:flex;align-items:center;gap:12px;}
.apptop .envtag{position:static;}
.iconbtn{width:32px;height:32px;border-radius:var(--radius-sm);border:1px solid var(--line);display:grid;place-items:center;background:var(--panel);cursor:pointer;color:var(--ink2);flex:none;}
.iconbtn:hover{border-color:var(--line2);background:var(--panel2);}
.iconbtn svg{width:15px;height:15px;stroke:currentColor;stroke-width:1.8;fill:none;stroke-linecap:round;stroke-linejoin:round;}
/* theme toggle shows the icon of the mode it will switch TO: moon in light, sun in dark */
#themetoggle .ic-moon{display:none;}
:root[data-theme="dark"] #themetoggle .ic-sun{display:none;}
:root[data-theme="dark"] #themetoggle .ic-moon{display:block;}
.avatar{width:32px;height:32px;border-radius:99px;background:var(--brand);color:var(--on-brand);font-weight:700;font-size:var(--fs-5);display:grid;place-items:center;flex:none;}
/* The bottom padding CLEARS THE FLOATING FEEDBACK PILL, which is position:fixed
   at bottom:14px and is ~34px tall — 48px of the bottom-right corner of every
   page, always on top. At 46px the last row of content sat directly under it:
   on /quotes/N that is the TOTAL AUD bar, i.e. the pill covered the one figure
   the page exists to state. 66px = 48 + a 18px breath. */
.appcontent{padding:26px 24px 66px;flex:1;min-width:0;}
/* Page width scale. Default is the 1180px shell; pages that want a different
   measure use a MODIFIER CLASS — never an inline max-width (a `.pagewrap[style]`
   !important override used to silently clamp every one of them to 1180px). */
.pagewrap{max-width:1180px;margin:0 auto;}
.pagewrap.tight{max-width:640px;}    /* single-column tablet screen (receipts) */
.pagewrap.narrow{max-width:840px;}   /* short settings / create form          */
.pagewrap.mid{max-width:920px;}      /* long form or checklist                */
.pagewrap.full{max-width:none;}      /* boards that own the whole viewport    */
.hamb{display:none;width:34px;height:34px;border:1px solid var(--line);border-radius:var(--radius-sm);cursor:pointer;background:var(--panel);place-items:center;flex:none;}
.hamb svg{width:16px;height:16px;stroke:var(--ink2);stroke-width:2;fill:none;stroke-linecap:round;}
.envtag{position:fixed;top:10px;right:12px;font-size:var(--fs-2);font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--ink3);background:var(--panel2);border:1px solid var(--line);border-radius:99px;padding:3px 10px;}
/* ---- topbar scope chip + search + bell ---- */
.tscope{display:inline-flex;align-items:center;gap:7px;background:var(--brand-tint);color:var(--brand-ink);font-weight:600;font-size:var(--fs-5);padding:6px 13px;border-radius:99px;border:1px solid var(--brand-tint2);white-space:nowrap;}
.tscope svg{width:13px;height:13px;stroke:currentColor;stroke-width:2;fill:none;}
/* sized by its wrapper so base.html needs no inline style */
.tsearch-wrap{flex:1;max-width:340px;min-width:0;}
.tsearch{width:100%;background:var(--panel2);border:1px solid var(--line);border-radius:var(--radius-sm);padding:8px 13px;color:var(--ink);font-size:var(--fs-5);}
@media (max-width:900px){.tsearch-wrap,.tsearch{display:none;}}
.tbell{position:relative;width:34px;height:34px;border-radius:var(--radius-sm);border:1px solid var(--line);display:grid;place-items:center;background:var(--panel);flex:none;}
.tbell svg{width:15px;height:15px;stroke:var(--ink2);stroke-width:1.8;fill:none;stroke-linecap:round;}
.tbell.on::after{content:"";position:absolute;top:6px;right:7px;width:7px;height:7px;border-radius:99px;background:var(--amber);border:1.5px solid var(--panel);}
/* unread count badge (was a <style> block in base.html, with the on-amber ink
   hard-coded for the third time) */
.tbell .nbadge{position:absolute;top:-6px;right:-6px;min-width:16px;height:16px;padding:0 4px;border-radius:99px;background:var(--amber);color:var(--on-amber);font-size:var(--fs-2);font-weight:700;line-height:16px;text-align:center;border:1.5px solid var(--panel);}
/* skip-to-content: has to out-layer everything it might land on top of */
.skiplink{position:absolute;left:-9999px;top:0;z-index:var(--z-skip);background:var(--panel);color:var(--ink);border:1px solid var(--brand);border-radius:var(--radius-sm);padding:9px 14px;font-size:var(--fs-6);font-weight:700;text-decoration:none;}
.skiplink:focus{left:10px;top:10px;}

/* ---- impersonation banner ----
   It owns the top edge of the scroll root. It is sticky, and .apptop and
   .appside are sticky at the SAME top:0 in the SAME root, so without an offset
   the banner painted over 36 of the topbar's 54px — all but the bottom 7px of
   the bell, the theme toggle, the avatar and (below 980px) the hamburger,
   which is the only way to reach the nav. One token, three consumers.
   The banner is pinned to a single line so --banner-h stays true at every
   width: a wrapped banner would grow past the offset it declares. */
.imp-banner{position:sticky;top:0;z-index:var(--z-banner);min-height:var(--banner-h);background:var(--amber);color:var(--on-amber);padding:7px 16px;font-size:var(--fs-5);font-weight:600;display:flex;justify-content:center;gap:14px;align-items:center;flex-wrap:nowrap;}
body:has(.imp-banner){--banner-h:36px;}
.imp-banner > span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.imp-banner form{flex:none;}
.imp-banner button{background:var(--navy);color:var(--on-navy);border:none;border-radius:99px;padding:3px 12px;font-size:var(--fs-4);font-weight:700;cursor:pointer;white-space:nowrap;}

@media (max-width:980px){
  .appframe{grid-template-columns:1fr;}
  .appside{display:none;position:static;height:auto;overflow:visible;}
  .navtoggle:checked ~ .appframe .appside{display:flex;}
  .navscroll{flex:none;overflow:visible;}
  .navscroll::after{display:none;}
  .hamb{display:grid;}
  .appcontent{padding:20px 14px 60px;}
}

/* ---- FOCUS SHELL: `body.sched-focus` -----------------------------------
   The ONE page-scoped exception to the scrolling-document shell above, and
   the only rules in this file gated on a body class. schedule_board.html is
   the sole template that emits the hook (base.html {% block body_class %}),
   so nothing here can reach another page, and because the hook is rendered
   per-request there is no state to leak: navigating off /schedule serves a
   plain <body> and the normal shell comes straight back.

   Two jobs:
   1. THE PAGE DOES NOT SCROLL. The frame is pinned to the viewport and every
      wrapper down to .appcontent gives up its padding and its min-height, so
      the board pane inside owns 100% of the leftover height and any overflow
      scrolls THERE. Without `min-height:0` on each flex/grid child a nested
      scroller cannot shrink below its content and the overflow escapes back
      out to the document, which is exactly the bug being fixed.
   2. THE NAV AUTO-COLLAPSES so the board gets the full width. It is restored
      by base.html's EXISTING #navtoggle checkbox + .hamb label — the control
      already in the topbar for the <=980px hamburger, simply un-hidden here.
      No new control, no JavaScript, and nothing that can persist off-page. */
body.sched-focus{overflow:hidden;}
body.sched-focus .appframe{height:calc(100vh - var(--banner-h));min-height:0;overflow:hidden;}
body.sched-focus .appmain{min-height:0;overflow:hidden;}
body.sched-focus .appcontent{padding:0;min-height:0;overflow:hidden;display:flex;flex-direction:column;}
@media (min-width:981px){
  body.sched-focus .appframe{grid-template-columns:0 minmax(0,1fr);}
  /* width+visibility, NOT display:none — the rail keeps its flex layout so
     re-opening it does not re-run a cold layout, and visibility:hidden takes
     its links out of the tab order while it is shut. */
  body.sched-focus .appside{width:0;padding-left:0;padding-right:0;border-right:none;visibility:hidden;}
  /* `margin:0` because .hamb is a <label>, and the global form rule at the top
     of this file gives every label `margin:14px 0 5px`. That 19px inflates the
     topbar from 57px to 76px the moment the hamburger is shown — 19px straight
     off a height budget that is being counted to the pixel here.
     NOTE (pre-existing, deliberately NOT fixed globally): the same 19px is
     already being paid on EVERY page below 981px, where playpro.css shows the
     same .hamb. Fixing it there changes the mobile topbar on every screen in
     the app, which is outside this change — it is reported, not smuggled in. */
  body.sched-focus .hamb{display:grid;margin:0;}
  body.sched-focus .navtoggle:checked ~ .appframe{grid-template-columns:222px minmax(0,1fr);}
  body.sched-focus .navtoggle:checked ~ .appframe .appside{width:auto;padding-left:12px;padding-right:12px;border-right:1px solid var(--line);visibility:visible;}
}

/* ============================ page header ============================ */
.page-head{display:flex;justify-content:space-between;align-items:flex-end;gap:16px;flex-wrap:wrap;margin-bottom:22px;}
.page-head .titles{min-width:0;}
/* overflow-wrap:anywhere pairs with .titles{min-width:0} above. The container
   was already allowed to shrink, but the h1 inside it had no BREAK
   OPPORTUNITY, so a long unbroken token (a 92-char council/school account
   name — ordinary for this business) pushed the whole document sideways:
   /accounts/336 had 240px of real horizontal page scroll at 1024x768 and
   232px at 800x1280. Both are tablet viewports, i.e. the crew and supervisor
   hardware; it measured clean at 1440x900, which is why three desktop passes
   missed it. text-wrap:balance is kept — the two do not conflict. */
.page-head h1{font-family:'Inter',system-ui,sans-serif;font-weight:600;font-size:var(--fs-11);color:var(--ink);line-height:1.15;letter-spacing:-.01em;text-wrap:balance;overflow-wrap:anywhere;}
.page-head .page-sub,.page-sub{color:var(--ink2);font-size:var(--fs-5);margin-top:4px;}
.page-head .actions{display:flex;gap:10px;align-items:center;flex-wrap:wrap;}
.page-head .pill,.page-head .tag{vertical-align:middle;}
/* .metarow — the chip/status strip that sits under a page title (status pill,
   archetype, lane, crew avatars, a "+ crew" disclosure). It was an inline
   `display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:8px` on a
   <p class="page-sub">, and a <p> CANNOT hold the <details>/<form>/<div> that
   row contains — HTML5 implied-end-tags closed the paragraph at the first one
   and ejected the disclosure onto its own line. Named here so the element is a
   <div> and the geometry is not retyped per page. The second selector is
   needed because `.page-head .page-sub` (0,2,0) out-specifies `.metarow`
   (0,1,0) and would otherwise win the margin-top back to 4px. */
.metarow,.page-head .page-sub.metarow{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:8px;}
/* breadcrumb / back-link above a title */
.back,.topnav{font-size:var(--fs-5);margin-bottom:6px;color:var(--ink3);}
.back a,.topnav a{text-decoration:none;font-weight:600;color:var(--brand-ink);}
.back a:hover,.topnav a:hover{text-decoration:underline;}
/* `.greet-h1` (a 25px/700 dashboard greeting) is GONE. It was inert anyway —
   `.page-head h1` (0,1,1) outranked `.greet-h1` (0,1,0), so the greeting always
   rendered at the standard 23px/600 — and a dead class invites someone to
   "restore" it later and re-introduce an h1 size that no other page has.
   text-wrap:balance, the one thing it carried that was worth keeping, is now
   on `.page-head h1` for every page. */

/* ============================ sub-navigation tabs ============================ */
.tabs{display:flex;gap:8px;flex-wrap:wrap;margin:0 0 18px;}
.tab{border:1px solid var(--line2);border-radius:99px;padding:5px 14px;font-size:var(--fs-5);font-weight:600;color:var(--ink2);text-decoration:none;background:var(--panel);cursor:pointer;transition:background .12s ease,color .12s ease;}
.tab:hover{background:var(--panel2);color:var(--ink);}
.tab.on{background:var(--brand);color:var(--on-brand);border-color:var(--brand);}
/* right-aligned primary-coloured tab (e.g. New tour) */
.tab.cta{margin-left:auto;background:var(--brand);color:var(--on-brand);border-color:var(--brand);font-weight:700;}
.tab.cta:hover{background:var(--brand);color:var(--on-brand);filter:brightness(.96);}
/* .chip — the SAME control shape doing a different job: a filter applied to
   the current view, not sub-navigation to another one. Assets and Credentials
   rendered filter rows that were pixel-identical to Maintenance's page-change
   tab row, so the control did not say which it was. Squared corner + lighter
   resting weight distinguishes filter from navigation. */
.tab.chip{border-radius:var(--radius-sm);font-weight:500;background:var(--panel2);}
.tab.chip.on{background:var(--brand-tint);color:var(--brand-ink);border-color:var(--brand-tint2);font-weight:700;}
.tab .n{opacity:.7;font-weight:700;margin-left:4px;font-variant-numeric:tabular-nums;}

/* ============================ pills / status ============================ */
.pill{display:inline-block;border-radius:99px;font-size:var(--fs-3);font-weight:700;letter-spacing:.06em;text-transform:uppercase;padding:2px 9px;white-space:nowrap;vertical-align:middle;line-height:1.5;}
/* semantic variants — the canonical vocabulary */
.pill.ok,.pill.good,.pill.success{background:var(--brand-tint);color:var(--brand-ink);}    /* positive / active (tinted) */
.pill.soft{background:var(--brand-tint2);color:var(--brand-ink);}                          /* mild positive / in-progress */
.pill.neutral,.pill.info{background:var(--panel2);color:var(--ink2);border:1px solid var(--line);} /* informational */
.pill.warn,.pill.attn,.pill.pending{background:var(--amber-tint);color:var(--amber-ink);}  /* NEEDS ATTENTION only */
.pill.bad,.pill.danger,.pill.error{background:var(--danger-tint);color:var(--danger);}     /* error / declined */
.pill.quiet{background:var(--panel2);color:var(--ink2);border:1px solid var(--line);}
.pill.mute,.pill.off{background:transparent;color:var(--ink3);border:1px dashed var(--line2);} /* inactive / dismissed */
/* .pill.data — a pill carrying a VALUE rather than a status word.
   This is the gap that made eight pages invent their own chip. .pill is
   UPPERCASE + letter-spaced, which is right for "OVERDUE" and wrong for
   "-2d", "0d", "31 Jul" or "Site Plan" — so page after page hand-rolled a
   near-duplicate instead (.agepill, .wakepill, .regbadge, .metbadge,
   .manchip, .autochip, .optag, .mintchip). Because each was authored
   independently they landed on five different font-sizes (9.5/10/11/11.5/12)
   and different paddings and vertical-aligns, which is exactly the line-box
   mechanism documented on .etag below as the cause of "some UI cards are not
   really aligned" — the problem had simply moved to a new set of class names.
   Combine with any semantic variant: `class="pill data warn"`.
   tabular-nums so a column of these stays on one rhythm. */
.pill.data{text-transform:none;letter-spacing:0;font-weight:600;font-variant-numeric:tabular-nums;}

/* --- domain status pills (st-*) : one canonical semantic mapping ---
   These are reached DYNAMICALLY (`pill st-{{ job.status }}`, `st-{{ j.status }}`),
   so a class here having no literal occurrence in a template is NOT evidence
   it is dead. Do not prune this block by grep. */
.st-open,.st-confirmed,.st-approved,.st-enrolled,.st-hired,.st-published,.st-in_progress,
.st-interview_booked,.st-interviewed,.st-offered,.st-fulfilled,.st-accepted,.st-join,
.st-started{background:var(--brand);color:var(--on-brand);}
.st-active,.st-scheduled,.st-dispatched,.st-done,.st-complete,.st-shortlisted,.st-waitlisted,
.st-registered,.st-entered_ccms,.st-interview_requested,.st-ok,.st-receipt{background:var(--brand-tint2);color:var(--brand-ink);}
.st-completed,.st-deferred,.st-prospect,.st-future_potential,.st-pending,.st-submitted,
.st-archived,.st-closed{background:var(--panel2);color:var(--ink2);border:1px solid var(--line);}
.st-requested,.st-awaiting_approval,.st-applied,.st-draft,.st-over,.st-void,.st-interview_requested_new{background:var(--amber-tint);color:var(--amber-ink);}
.st-no_show,.st-declined,.st-unsuccessful,.st-issue{background:var(--danger-tint);color:var(--danger);}
.st-cancelled,.st-exited,.st-aged_out,.st-none,.st-todo,.st-unqualified,.st-withdrawn,.st-retired,.st-inactive{background:transparent;color:var(--ink3);border:1px dashed var(--line2);}

/* --- Playpro JOB pipeline states (SCOPE_V3 §J1) : the single source. ---
   These were re-declared in jobs_list / job_detail / account_detail /
   site_detail with two different palettes, so the same job showed a different
   chip depending on the screen. Any page rendering `pill st-{{ job.status }}`
   now gets the canonical colour for free. Ordered enquiry → paid; weight rises
   with commitment, amber = needs attention, strong solid = money is out. */
.pill.st-enquiry{background:var(--panel2);color:var(--ink2);border:1px solid var(--line);}
.pill.st-quoted{background:var(--brand-tint);color:var(--brand-ink);}
.pill.st-dormant{background:var(--amber-tint);color:var(--amber-ink);}
.pill.st-won{background:var(--brand);color:var(--on-brand);}
.pill.st-mobilising,.pill.st-scheduled{background:var(--brand-tint2);color:var(--brand-ink);}
.pill.st-in_progress{background:var(--brand);color:var(--on-brand);}
.pill.st-defects_handover{background:var(--amber-tint);color:var(--amber-ink);}
.pill.st-invoiced{background:var(--pill-strong);color:var(--on-pill-strong);}
.pill.st-paid{background:var(--brand-tint);color:var(--brand-ink);border:1px solid var(--brand);}
/* Lost (0042) is a closed side exit, not a spine step: the same quiet dashed
   treatment as the other "closed, not deleted" states (.st-cancelled et al). */
.pill.st-lost{background:transparent;color:var(--ink3);border:1px dashed var(--line2);}

/* --- entity tag (.etag) : ONE component, one shape ---------------------
   The four-entity chip was re-declared in TEN page <style> blocks plus two
   partials, at five different sizes (9 / 9.5 / 10 / 10.5 / 11px), two radii,
   four vertical-aligns and three margins — so the SAME job's entity chip
   changed the line-box height of whatever heading row it sat in, which is the
   literal mechanism behind "some UI cards are not really aligned".
   It is one component; it lives here, and page templates carry NO .etag rule.

   Tint rule: the per-entity colour drives the 3px LEFT RULE only, via the
   --etint custom property. The fill/ink pair stays --brand-tint/--brand-ink,
   which is the pair verify_ui.py AA-checks in both themes — deriving the fill
   from an arbitrary admin-chosen entity hex (what the tray and the schedule
   bar used to do with color-mix) puts the label's contrast outside the gate.
   Size modifiers, not new declarations:
     .etag       register rows, default
     .etag.sm    dense surfaces — kanban cards, tray items, schedule bars
     .etag.lg    the packet <h1>
     .etag.gap   sits inline straight after a number (no flex gap to lean on) */
.etag{display:inline-block;font-size:var(--fs-2);font-weight:800;letter-spacing:.06em;line-height:1.5;
  padding:1px 7px;border-radius:var(--radius-xs);background:var(--brand-tint);color:var(--brand-ink);
  border-left:3px solid var(--etint,var(--brand));vertical-align:1px;white-space:nowrap;}
/* SQUARE THE LEADING CORNER AT .sm ONLY. .etag carries the per-entity colour
   as a 3px border-left (so the fill can stay AA-safe — see above). At 9px the
   box is only ~13px tall, and a 3px rule against the inherited 6px
   --radius-xs corner stops reading as a RULE and starts reading as a curved
   sliver: on the kanban cards, the schedule tray and the schedule bars — the
   three densest surfaces in the product — the chip rendered as "( PD", i.e.
   a stray opening parenthesis printed before the entity code. Squaring only
   the leading corner keeps the rounded silhouette on the other three and
   changes nothing at the default and .lg sizes. */
.etag.sm{font-size:var(--fs-1);letter-spacing:.05em;padding:0 6px;vertical-align:0;border-radius:0 var(--radius-xs) var(--radius-xs) 0;}
.etag.lg{font-size:var(--fs-3);padding:1px 8px;vertical-align:2px;}
.etag.gap{margin-left:6px;}

/* metro / regional site badge. Declared twice (sites_list 10px, site_detail
   10.5px + a 2px baseline offset), so it jumped on every list -> detail click. */
.rgn{display:inline-block;font-size:var(--fs-2);font-weight:700;letter-spacing:.07em;text-transform:uppercase;border-radius:99px;padding:2px 9px;vertical-align:1px;}
.rgn.metro{background:var(--panel2);color:var(--ink2);border:1px solid var(--line);}
.rgn.regional{background:var(--amber-tint);color:var(--amber-ink);border:1px solid var(--amber-line);}
/* the conduit-contact badge — byte-identical copies in accounts_list and
   account_detail */
.conduit-badge{display:inline-block;font-size:var(--fs-2);font-weight:700;letter-spacing:.05em;text-transform:uppercase;background:var(--brand-tint);color:var(--brand-ink);border-radius:99px;padding:1px 8px;}
/* a job/quote history line on a client or site record — same, twice */
.histrow{display:flex;gap:10px;align-items:baseline;padding:8px 2px;border-top:1px solid var(--line);font-size:var(--fs-5);}
.histrow:first-of-type{border-top:none;}
.histrow .meta{color:var(--ink3);margin-left:auto;flex:none;font-size:var(--fs-4);}
/* possible-duplicate warning panel — same, twice */
.dupbox{background:var(--amber-tint);border:1px solid var(--amber-line);border-radius:var(--radius-sm);padding:12px 14px;font-size:var(--fs-5);margin-bottom:14px;}
.dupbox b a{color:var(--amber-ink);}

/* small neutral category chip (e.g. budget category) */
.tag,.cat{display:inline-block;font-size:var(--fs-3);color:var(--ink2);background:var(--panel2);border:1px solid var(--line);padding:1px 8px;border-radius:99px;white-space:nowrap;}
.attn{color:var(--amber-ink);font-weight:700;}
.neg,.over{color:var(--amber-ink);font-weight:700;}

/* callout note — muted dashed panel for intro/help text inside a page.
   Shared by uniform policy, integrations, owner overview. */
.notebar{font-size:var(--fs-4);line-height:1.55;color:var(--ink2);background:var(--panel2);border:1px dashed var(--line2);border-radius:var(--radius);padding:9px 13px;}
.notebar b{color:var(--ink);}
.notebar a{font-weight:600;}

/* ---- canonical KPI stat tiles — ONE grid of headline numbers. ----
   Three competing systems used to exist for this single job (.tiles/.tile,
   .kpirow/.kpilabel/.kpisub, and .vstrip rendering `card tile` — stacking two
   surface components so a 12px radius fought a 13px one on the same row).
   .tile is a surface in its OWN right: never write `class="card tile"`.
   The headline number is .tile .num (aliased to .kpi while templates migrate)
   at ONE size; there were four. */
.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:var(--gutter);margin:18px 0;}
/* .grid12 — put the tiles on the SAME 12-track grid as the card grid below
   them, so tile edges and card edges land on the same lines. auto-fit fitted
   only 6 tracks at the 1180px pagewrap, so the company dashboard's 7 tiles
   broke 6 + 1 and left ~970px of void directly above the cards. span 3 gives
   4 + 3 with a single empty track, on the grid's own gutters. */
.tiles.grid12{display:flex;flex-wrap:wrap;grid-template-columns:none;margin:0 0 14px;}
.tiles.grid12 > .tile{flex:1 1 calc(25% - var(--gutter));}
/* THE STEP LADDER IS span 3 / 6 / 12 — 4 then 2 then 1 tile per row.
   .tiles.grid12 is a FIXED 12-track grid (it is not auto-fit), so the tiles
   per row are 12/span: 4 then 3 then 2 then 1 under the old ladder. Any tile
   count not divisible by the ACTIVE step leaves a lone tile stranded on its
   own row, which reads as a rendering fault rather than a layout choice.
   The span-4 step (3 per row) was the worst offender because 3 divides almost
   nothing the app ships: /maintenance's 4-tile strip broke 3+1 at 1024px —
   the supervisor's tablet viewport — with a single 282px tile alone on a
   1170px row. Folding that step into span 6 leaves 4/2/1, so a 4-tile strip
   is orphan-free at EVERY width (4 / 2+2 / 1-per-row). Only two templates use
   this grid (company_dashboard, maint_home) so there is no wider fallout, and
   maint_home's 4 tiles are now clean at 1440, 1024 and 800.
   IT IS NOW A WRAPPING FLEX ROW, not a fixed 12-track grid, and that is what
   finally closes the orphan. A fixed track grid can only ever be orphan-free
   at counts divisible by the step, and this strip's count is NOT a constant:
   company_ui.py emits 5 base KPIs plus one for Xero overdue plus one for Xero
   revenue, so the harness renders 5 and PRODUCTION (Xero connected) renders 7.
   No single span divides both — 4 and 8 are the only clean counts, and the
   strip is the owner_exec landing page's masthead, so the stranded tile was
   the first thing Frits saw. With `flex:1 1 calc(25% - gutter)` the step
   ladder is identical (4 / 2 / 1 per row, same three widths) but the LAST ROW
   GROWS to consume the width it has: 5 -> 4 + one full-width, 7 -> 4 + three
   thirds, 6 -> 4 + two halves. Every row is full at every count.
   `grid-template-columns:none` resets the inherited `.tiles` grid track list
   so the flex basis is the only thing sizing a tile.
   The basis steps are container-aware for the same reason the card ladder is
   (see THE LADDER, below): the strip sits inside .appcontent, not the window.
   STILL WORTH DOING in python, and reported as a finding rather than done
   here: make the KPI count deterministic (4 or 8) so the strip is a clean
   rectangle rather than a filled-but-uneven one. Note the run brief's
   "kpis 5 -> 4" is right for the harness and WRONG for production, which
   would go 7 -> 6. */
@media (min-width:981px) and (max-width:1169px),(min-width:688px) and (max-width:927px){
  .tiles.grid12 > .tile{flex-basis:calc(50% - var(--gutter));}
}
@media (max-width:687px){.tiles.grid12 > .tile{flex-basis:100%;}}
/* padding matches .card exactly — the two used to differ by 2-4px, so the left
   inner text edge shifted between the tile row and the cards beneath it. */
.tile{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);padding:var(--pad-card);display:flex;flex-direction:column;box-shadow:var(--shadow);min-width:0;}
.tile.dense{padding:12px 16px;}
.tile.dense .num{font-size:var(--fs-10);}
/* money must never break mid-numeral: "$1,234," / "567" still looks like a
   valid figure. Scale down instead of wrapping. */
.tile .num,.kpi{font-size:var(--fs-12);font-weight:700;color:var(--ink);line-height:1.2;letter-spacing:-.01em;overflow-wrap:normal;word-break:keep-all;font-variant-numeric:tabular-nums;}
.tile .num small,.kpi small{font-size:var(--fs-6);font-weight:600;color:var(--ink2);letter-spacing:0;}
.tile .lbl{margin-top:2px;}
/* label-FIRST tile (both dashboards render label → number → sub). Reserve two
   label lines so the headline numbers share one baseline across the row
   regardless of whether a label wraps, and bottom-pin the sub-line. */
.tile > .lbl:first-child{margin-top:0;margin-bottom:6px;min-height:3em;}
.tile > .sub:last-child{margin-top:auto;padding-top:4px;}
.tile.warn .num,.kpi.over{color:var(--amber-ink);}
.kfoot{margin-top:auto;padding-top:6px;font-size:var(--fs-5);font-weight:700;}
.kfoot a{text-decoration:none;}
.kfoot a:hover{text-decoration:underline;}

/* ---- inline icon glyphs ----
   Replacements for the colour emoji that used to sit inside pills and chips:
   emoji ignore `currentColor`, so a blue-grey 💤 inside an amber .pill.warn
   never matched its chip, and it rendered as a different picture on the crew
   tablet, Windows and iOS. These inherit the chip's ink in both themes.
   `.ic-alert` is the banner/severity glyph — use it instead of ⚠️. */
.ic-wake,.ic-slot,.ic-clip,.ic-alert,.lockb svg{width:1.05em;height:1.05em;stroke:currentColor;stroke-width:2;fill:none;
  stroke-linecap:round;stroke-linejoin:round;vertical-align:-.15em;flex:none;}
.ic-clip{stroke-width:1.8;}

/* ============================ flash / notices ============================ */
.flash{background:var(--brand-tint);color:var(--brand-ink);border-radius:var(--radius-sm);padding:10px 14px;font-size:var(--fs-5);font-weight:600;margin:0 0 16px;}
.flash.ok,.flash-ok{background:var(--brand-tint);color:var(--brand-ink);}
.flash.warn,.flash-warn{background:var(--amber-tint);color:var(--amber-ink);}
.flash.err,.flash.error{background:var(--danger-tint);color:var(--danger);}
.error{background:var(--danger-tint);color:var(--danger);border-radius:var(--radius-sm);padding:10px 14px;font-size:var(--fs-5);margin-top:14px;}
/* standing attention banner — a persistent amber cue that (unlike .flash) is
   NOT promoted into a transient toast; use for triage/attention counts. */
.attn-banner{background:var(--amber-tint);color:var(--amber-ink);border:1px solid var(--amber);border-radius:var(--radius-sm);padding:10px 14px;font-size:var(--fs-5);font-weight:600;margin:0 0 16px;}
.attn-banner a{color:var(--amber-ink);font-weight:700;}
/* ONE urgency ladder. Plain amber = "due soon / chase it". `.critical` = the
   rung above it, and it wears --danger, which themes — unlike the hardcoded
   #C6373E the asset out-of-service banner used to carry (the only unthemed
   surface left in the dark app, and shouting louder than an expired licence). */
.attn-banner.critical{background:var(--danger-tint);color:var(--danger);border-color:var(--danger);}
.attn-banner.critical a{color:var(--danger);}
.error.flush,.flash.flush{margin:0 0 16px;}
/* the single ERROR banner. .oosbanner (asset out-of-service) and
   .svcbar.overdue used to hard-code #C6373E — a fourth red that, unlike
   --danger, never lightened for the dark theme, so the most severe state in
   the product read as a different severity language from every other one. */
.errbanner{background:var(--danger);color:var(--on-pill-strong);border-radius:var(--radius-sm);padding:10px 14px;font-size:var(--fs-5);font-weight:700;display:flex;align-items:center;gap:8px;margin:0 0 16px;}
/* free-text message / body panel */
.msg{background:var(--panel2);border-radius:var(--radius);padding:12px 16px;font-size:var(--fs-7);margin-top:12px;white-space:pre-wrap;overflow-wrap:anywhere;}

/* ============================ key / value ============================ */
/* .kv — labelled facts about one record. Works with EITHER <span class="k/v">
   pairs or real <dl><dt>/<dd> markup, because four incompatible
   implementations of this existed (asset_detail redefined .kv itself at a hard
   130px key column, maint_wo_detail called it .meta, settings_entities called
   it .ent-rows and right-aligned the values) — so nothing lined up between an
   Asset page and the Work Order page describing the same machine. */
.kv{display:grid;grid-template-columns:max-content 1fr;gap:7px 18px;font-size:var(--fs-6);align-items:baseline;}
.kv .v,.kv > dd{overflow-wrap:anywhere;margin:0;}
/* two key/value pairs per row on a wide card (was maint_wo_detail's .meta) */
@media (min-width:641px){.kv.two{grid-template-columns:max-content 1fr max-content 1fr;}}
@media (max-width:480px){.kv,.kv.two{grid-template-columns:1fr;gap:1px 0;}.kv .v,.kv > dd{margin-bottom:7px;}}
/* .kvlist — the in-card fact list (label left, value right on ONE line).
   It was declared only inside job_detail.html's <style>, but job_completion
   uses it four times from its own head block, so the entire right rail of the
   completion tab rendered as unstyled UA <ul>: disc bullets, 40px indent, key
   and value run together. One definition, here. */
.kvlist{list-style:none;margin:0;padding:0;font-size:var(--fs-5);}
.kvlist li{display:flex;justify-content:space-between;gap:10px;padding:4px 0;}
/* flex:none on the KEY, min-width:0 on the VALUE — not both on both.
   A flex item's automatic minimum is min-content, and `min-width:0` removes
   it. On the key that is wrong: it let a LABEL shrink below its own text, so
   the label overflowed its box and painted straight through the value beside
   it. Measured on /jobs/59 at 1440x900: "Customer" was a 28px box holding
   58px of text, painting to x=1114 while its value started at x=1095 — 19px
   of literal text-on-text overlap, on every job detail page, not just the
   long-name fixtures. A label must never shrink; the VALUE is the flexible
   side, and it needs a break opportunity or an unbroken customer/site name
   propagates out to the document (225px of page h-scroll on /jobs/60).
   .msg above already uses overflow-wrap:anywhere — .kvlist was just missed. */
.kvlist .k{color:var(--ink3);flex:none;}
.kvlist .v{font-weight:600;text-align:right;min-width:0;overflow-wrap:anywhere;}
.kvlist .v.num{font-variant-numeric:tabular-nums;}
/* .deflist — the <dl> money/count list. company_dashboard (.ledger-rows,
   12.4px) and admin_xero (.xr-rows, 12.5px) shipped the same component twice. */
.deflist{font-size:var(--fs-5);margin:0;}
.deflist > div{display:flex;justify-content:space-between;gap:12px;padding:7px 0;border-bottom:1px solid var(--line);}
.deflist > div:last-child{border-bottom:none;}
.deflist dt{color:var(--ink3);}
.deflist dd{margin:0;text-align:right;font-variant-numeric:tabular-nums;font-weight:600;}
/* .grid2 — the two-up card pair (quote_view, account_detail, site_detail,
   profile, settings_rates, quote_xero_checklist, admin_user_edit).
   minmax(0,1fr) IS NOT COSMETIC. A bare `1fr` means `minmax(auto,1fr)`, whose
   automatic minimum is the item's MIN-CONTENT width — so a track holding a
   wide <table> refuses to shrink below that table and blows out past the
   container. On /quotes/28 the 8-column .linetbl forced the right track to
   671px inside a 1024px viewport: the AMOUNT column (the only one carrying
   money) rendered entirely off-screen, and the page picked up 129px of real
   horizontal scroll. Worse, the expansion silently DEFEATED the
   .tablecard{overflow-x:auto} scroll container that already existed — the
   card never got a constrained track to scroll inside, so the overflow was
   handed to the document instead. Measured after this fix: the tablecard
   goes 671px -> 370px wide, scrolls=TRUE, page h-scroll 129px -> 0.
   This is the SAME bug the last pass fixed for .cardgrid's bare .wN tracks.
   .grid2 was the last shared grid container in the file without the reset —
   .cardgrid > *, .form-grid > * and .cardcol all already carry min-width:0. */
/* align-items:START, not stretch. A .grid2 pairs ONE long thing with ONE short
   thing by design — a form beside a reference panel — so stretching the short
   one to the tall one's height turns the difference into an empty card.
   Measured at 1440 before this: /sites/1 "Assets under maintenance" painted
   190px inside a 675px card (485px of void, 72% of the pair — the worst in the
   app), /profile "About you" 152/357, /accounts/1 "AP rules" 506/763. A card
   is a surface with a border and a background, so an empty one reads as
   content that failed to load; the same whitespace as page background reads as
   a short panel, which is what it is. This is the OPPOSITE call to the one
   .cardgrid.autofill makes — see the long note there — and for the opposite
   reason: .grid2 has exactly two items and no last row to strand, so nothing
   is gained by keeping the heights locked together. */
.grid2{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:var(--gutter);align-items:start;}
.grid2 > *{min-width:0;}
/* A .grid2 HOLDING ONE CARD IS A ONE-COLUMN PAGE, and it must not keep the
   second track. site_detail.html and account_detail.html both wrap their
   right-hand card in `{% if mode != 'new' %}`, so /sites/new and /accounts/new
   render the CREATE FORM — the entire purpose of those two pages — into a
   578px half at 1440 with a dead 578px track beside it, and into 370px at
   1024, which is a two-column .form-grid squeezed to 178px per field on the
   supervisor's tablet. Measured on both pages at both widths before this rule.
   :only-child covers it wherever it happens rather than page by page. */
.grid2 > :only-child{grid-column:1 / -1;}
@media (max-width:820px){.grid2{grid-template-columns:minmax(0,1fr);}}

/* ============================ result meta / count ============================ */
.count{color:var(--ink2);font-size:var(--fs-5);margin:16px 2px 6px;}
.count b{color:var(--ink);}
.footnote{color:var(--ink3);font-size:var(--fs-4);margin:14px 2px 0;}

/* ============================ data tables ============================ */
/* real <table> */
.data-table{width:100%;border-collapse:collapse;font-size:var(--fs-6);}
/* ---- COLUMN WEIGHTING. `table-layout:auto` sizes columns by content, and on
   a register that hands width to whichever column happens to hold the longest
   string — which is reliably a status enum, never the column anyone acts on.
   Measured on /jobs at 1440 (table 1168px): State 188px for a pill, Updated
   162px for a timestamp that is identical on every row, Value 115px for a
   column of em-dashes — against Site / title on 215px, which wrapped EVERY
   row's title to three lines and made the page 3857px tall.
   `width:1%` + nowrap is the shrink-to-content idiom: a declared width that
   cannot be satisfied collapses the column onto its own min-content, and the
   slack goes to the columns that declared nothing. Put .c-min on the enum,
   code and timestamp columns and .c-lead on the one the reader is scanning.
   Both go on the <th> — a column's width is decided by the widest declaration
   in the column, and the header is the one place it can be stated once. */
.data-table th.c-min{width:1%;white-space:nowrap;}
.data-table td.c-min{white-space:nowrap;}
.data-table th.c-lead{width:34%;}
.data-table th{text-align:left;padding:12px 16px;border-bottom:1px solid var(--line);white-space:nowrap;}
.data-table td{padding:11px 16px;border-bottom:1px solid var(--line);vertical-align:middle;}
.data-table tbody tr:last-child td{border-bottom:none;}
.data-table tbody tr:hover td{background:var(--panel2);}
.data-table td.num,.data-table th.num{text-align:right;font-variant-numeric:tabular-nums;}
/* links in registers must be identifiable WITHOUT hover — the crew tablets
   have no hover state, so weight alone was not an affordance. */
.data-table a,.rowlink{font-weight:700;color:var(--ink);text-decoration:underline;text-decoration-color:var(--ink3);text-underline-offset:2px;text-decoration-thickness:1px;}
.data-table a:hover,.rowlink:hover{color:var(--brand-ink);text-decoration-color:currentColor;}
/* .rowlink is the same affordance for the grid-row "tables" that are not real
   <table>s (work-order numbers, file names, entity names, client/site history
   rows). Seven pages had turned the underline OFF with
   `text-decoration:none;color:inherit`, which is exactly the no-hover-state
   crew-tablet case the rule above exists for. */
.srow .no a,.hrow .no a,.histrow .no a,.filerow .nm a,.ename a,.wotable .wono a{
  font-weight:700;color:var(--ink);text-decoration:underline;text-decoration-color:var(--ink3);text-underline-offset:2px;text-decoration-thickness:1px;}
.srow .no a:hover,.hrow .no a:hover,.histrow .no a:hover,.filerow .nm a:hover,.ename a:hover,.wotable .wono a:hover{
  color:var(--brand-ink);text-decoration-color:currentColor;}
/* RETIRED / INACTIVE rows. Five registers dimmed these with `opacity:.55`,
   which composites the text toward the panel: --ink lands at ~3.87:1 and the
   --ink3 sub-line at ~2.19:1, both below the 4.5:1 AA floor — and opacity is
   invisible to verify_ui.py, which only tests the tokens. An explicit muted
   ink keeps AA. The state is already carried non-colour by a "retired" /
   "inactive" chip on all five, so nothing is lost. */
.data-table tr.is-retired td,.drow.is-retired,.erow.is-retired,.prog.is-paused{color:var(--ink3);}
.data-table tr.is-retired td a{color:var(--ink2);}
.data-table tr.is-retired .pill,.erow.is-retired .pill{filter:saturate(.55);}
.tablecard{padding:0;overflow-x:auto;}
/* density modifier for in-card SUB-tables (invoice lines, sync status, ledger
   widgets). Same header colour, border weight and alignment as the register
   tables — only the padding steps down, so the two read as one component
   rather than the seven bespoke table styles that used to exist. */
.data-table.dense{font-size:var(--fs-5);}
.data-table.dense th{padding:6px 10px 6px 0;}
.data-table.dense td{padding:8px 10px 8px 0;}
.data-table.dense tbody tr:hover td{background:transparent;}

/* ============================ empty states ============================ */
.empty{color:var(--ink3);padding:30px 4px;text-align:center;font-size:var(--fs-5);}
.empty a{font-weight:600;}
.empty .em-lead{display:block;font-family:'Inter',system-ui,sans-serif;font-weight:600;font-size:var(--fs-9);color:var(--brand-ink);margin-bottom:4px;}
.empty svg{width:26px;height:26px;stroke:var(--brand);stroke-width:1.6;fill:none;stroke-linecap:round;stroke-linejoin:round;margin-bottom:8px;opacity:.8;}
/* in-card density modifier — same ANATOMY (lead line + body), tighter box and
   left-aligned so it reads as a widget note rather than a page-sized void.
   Three undesigned variants used to exist (.empty.tight, .xr-empty and raw
   `<p style="color:var(--ink3);font-size:var(--fs-6)">`), so an all-clear card looked
   different depending on which page you were on. */
.empty.tight{padding:14px 0 6px;text-align:left;font-size:var(--fs-5);}
.empty.tight .em-lead{font-size:var(--fs-7);margin-bottom:2px;}

/* ============================ responsive utility ============================ */
@media (max-width:820px){
  .hide-sm{display:none!important;}
  /* .hide-sm must NEVER hide a live form control: the value goes on being
     submitted with no way to see or edit it. Anything holding one stays put —
     put the table in a .scroll-x / .tablecard instead, or pair it with the
     .mobile-line-card pattern below. */
  /* :not([hidden]) is load-bearing. `display:revert!important` at specificity
     (0,2,1) out-ranks the global `[hidden]{display:none!important}` (0,1,0),
     so without this guard an element that is BOTH .hide-sm and hidden would be
     forced back on screen below 820px — the tablet tier.

     button / a[href] / [role=button] are load-bearing too. The guard
     originally whitelisted only input/select/textarea, so it did not match a
     td.hide-sm containing a <form><button>. _job_variations.html:20 is
     exactly that shape: on a tablet in PORTRAIT the cell computed to
     display:none and the "send confirmation" button measured 0x0 — present in
     the DOM, invisible, with no alternative path on the page and nothing to
     indicate anything was missing. A supervisor simply could not flag a
     variation confirmation as sent, and the chase workflow stalled with no
     visible cause. That is the same failure class this guard was ADDED for
     (the quote builder hiding Unit and Rate), recurring through a gap in its
     own fix — so the rule now covers CONTROLS AND ACTIONS, not just fields.
     verify_ui.py::check_hide_sm_interactive greps the templates for the same
     condition so a new one fails at commit time.

     a[href] is deliberately NOT in this list. A link is navigation, not an
     action: hiding a shortcut COLUMN on a narrow screen is a legitimate
     responsive choice (quote_register/quotes_list hide a Xero-status link and
     a job-number link, both reachable from the record itself), and
     whitelisting links here would force those columns back on and widen two
     registers on the tablets for no gain. The gate reports links as a WARNING
     so a genuinely-only-path link still gets seen. */
  .hide-sm:has(input:not([type=hidden]),select,textarea,button,[role=button]):not([hidden]){display:revert!important;}
}

/* ============================ dashboard (role-aware home) ============================ */
/* .dash-note deleted 2026-07-31 — 0 adopters. (.ksub, same, removed from the
   .sub token's selector list above.) */
/* the "nothing outstanding" line inside an exception card. This used to be
   declared here at 15px/700/brand AND re-declared in dashboard.html at
   12.3px/ink3/flex — the page copy won and this one had no consumer. */
.allclear{color:var(--ink3);font-size:var(--fs-5);padding:8px 0 2px;display:flex;align-items:center;gap:7px;}
.allclear svg{width:14px;height:14px;stroke:var(--brand-ink);stroke-width:2;fill:none;stroke-linecap:round;stroke-linejoin:round;flex:none;}
/* greeting attention checklist (deep links share widget queries; count can't drift) */
.attn-list{display:flex;flex-wrap:wrap;gap:5px 16px;margin-top:7px;}
.attn-list a{font-size:var(--fs-5);font-weight:600;color:var(--amber-ink);text-decoration:none;}
.attn-list a:hover{text-decoration:underline;}

@media (prefers-reduced-motion:reduce){*{transition:none!important;}}

/* remove duplicate page title in topbar (page-head h1 carries it) */
.apptop .pagetitle{display:none;}

/* In-page anchors must land CLEAR of the sticky topbar (and the impersonation
   banner above it). This lived inside _touchbar.html's phone-only media query,
   so every #anchor jump on desktop — including the field links in a form's
   validation summary — put the target under the topbar. */
[id]{scroll-margin-top:calc(var(--banner-h) + 64px);}

/* THE SCROLL CONTAINER IS UNCONDITIONAL. It used to live inside the
   max-width:760px block below, which meant that above 760px a .data-table
   wider than its card simply CLIPPED — no scrollbar, no cue, no reflow, the
   right-hand columns just gone. 39 templates render a .data-table and only 15
   wrap it in .tablecard, so 24 registers had no scroll container at any width
   above 760px — and 761-1180px is exactly the band the crew tablets and small
   laptops live in. `overflow-x:auto` is a no-op when nothing overflows, so
   hoisting it out is a zero-visual-change edit at every width where the table
   already fits, and it retro-fixes all 24 at once.
   The SIZING rules (min-width, nowrap, .wrapcell) stay phone-only below —
   those DO change layout and are only wanted when the table must scroll. */
.tablecard,.card:has(.data-table){overflow-x:auto;-webkit-overflow-scrolling:touch;}

/* WHICH COLUMN ABSORBS THE SLACK. `.data-table th{white-space:nowrap}` (above)
   makes every header claim its full width, and under table-layout:auto the
   short numeric columns then take content width while the one free-text column
   is left with the remainder. On /quotes/28 (66 lines) Description settled at
   ~135px — one or two words per line, a single quote line 25 rows tall — with
   ~120px of dead space between DESCRIPTION and QTY and the table filling only
   ~640px of a wider card. `width:100%` on ONE cell is the standard way to tell
   the auto algorithm to give that column every remaining pixel; the others keep
   their content width. EXACTLY ONE `.grow` per table.
   Pairs with `.wrapcell` (phone block below): .grow decides who gets the space,
   .wrapcell decides who is allowed to wrap. Together they are the column-sizing
   vocabulary .data-table previously did not have at all. */
.data-table th.grow{width:100%;}

/* mobile: horizontal-scroll tables (prevents header/value overlap & right-edge clipping) */
@media (max-width:760px){
  .data-table{min-width:640px;}
  .data-table th,.data-table td{white-space:nowrap;}
  /* …EXCEPT a free-text cell. `white-space:nowrap` makes a cell as wide as its
     longest unbroken line, so one verbose work-order description set the whole
     board's scroll extent and pushed Status/SLA/Raised hundreds of px away.
     Opt a free-text column out with .wrapcell. */
  .data-table td.wrapcell,.data-table th.wrapcell{white-space:normal;min-width:180px;max-width:260px;}
  .tablecard{overflow-x:auto;-webkit-overflow-scrolling:touch;}
}

/* ===================================================================
   THE CARD GRID. One system, one gutter, one breakpoint.
   `.dwrap` and `.dgrid`, the two historical aliases, are GONE — dashboard.html
   and company_dashboard.html now use `.cardgrid` like everything else.
   The aliases used to disagree on gap (14 vs 15px), default span (6 vs 4) and
   breakpoint (980 vs 900px); only `.dwrap` cancelled the 16px `.card + .card`
   sibling margin (so a `.dgrid`-only page staggered its 2nd card down 16px);
   and BOTH only ever sized `.card.wN`, so a bare `<div class="w8">` COLUMN
   WRAPPER — which is how job_detail.html and job_completion.html build their
   two-column packet — got no span at all and collapsed to a single 1/12 track.
   Sizing is now on the grid's DIRECT CHILDREN whatever they are.
   =================================================================== */
.cardgrid{display:grid;grid-template-columns:repeat(12,1fr);gap:var(--gutter);align-items:stretch;}
.cardgrid > *{grid-column:span 4;min-width:0;}
.cardgrid > .w4{grid-column:span 4;}
.cardgrid > .w6{grid-column:span 6;}
.cardgrid > .w8{grid-column:span 8;}
.cardgrid > .w12{grid-column:span 12;}
/* the grid owns the gutter — the flow sibling margin must not add to it */
.cardgrid .card + .card{margin-top:0;}
/* The grid is align-items:stretch, so cards in a row are equal height — but
   their content was top-packed with nothing pinned to the bottom, so a card
   with 2 rows next to one with 5 left its "+N more →" footer floating
   mid-card over dead space. Column-flex + margin-top:auto on the footers. */
.cardgrid > .card{display:flex;flex-direction:column;}
.cardgrid .xmore,
.cardgrid > .card > .footnote:last-child{margin-top:auto;}
.cardgrid > .card.hint .allclear{margin-block:auto;}
/* ---- THE LADDER: 3-up / 2-up / 1-up, keyed to the CONTAINER, not the window.
   THREE steps, not one. w4 exception cards used to go 3-up straight to
   full-width at 980px, so a landscape tablet (1024px — a real crew device)
   got ~270px of inner width per card and the pills wrapped, while a portrait
   tablet got 700px+ holding one short row. 2-up is the missing band.

   THE BREAKPOINTS ARE OFFSET FOR THE SIDEBAR, and that is the whole point of
   the arithmetic below. `.cardgrid` never sees the viewport: it sees
   `.appcontent`, which is `viewport − 222px sidebar − 48px padding` while the
   sidebar is up (>=981px) and only `viewport − 28px` once the sidebar
   collapses (<=980px), both capped at the 1180px .pagewrap. The old media
   queries were keyed to the viewport, so the two systems ran 222px out of
   step and the ladder stepped on the WRONG side of the container width:
     - 981px viewport -> container 711 -> 2-up -> 348px cards
     - 980px viewport -> container 952 -> 1-up -> ONE 952px card
   i.e. shrinking the window by 1px made the content 241px WIDER and dropped a
   whole step of the ladder, and the narrowest card column in the entire app
   (274px) was served at a 1121px viewport — narrower than at 1024 or at 980.
   Container queries are the natural instrument but `container-type:inline-size`
   also makes .appcontent the containing block for `position:fixed`, and every
   modal in the app (.confirmveil, .mvveil, .lightbox) is fixed INSIDE the page
   content — it would pin them to the content column instead of the viewport.
   So the offset is baked into the media queries instead.
   Container thresholds: 3-up >= 900, 2-up 660-899, 1-up < 660.
     sidebar up   (container = vw-270): 3-up vw>=1170, 2-up vw 981-1169
     sidebar down (container = vw-28) : 3-up vw>=928,  2-up vw 688-927, 1-up <688
   If .appcontent's padding or the sidebar width ever changes, these six
   numbers change with them. */
/* AT THE 2-UP STEP EVERY CARD IS HALF, INCLUDING .w8 — it used to go to 12
   while .w4 stayed at 6, and 12 + 6 does not sum: the band rendered a
   full-width block followed by a HALF-WIDTH BLOCK WITH A DEAD HALF-ROW BESIDE
   IT, measured 418x523px at 1120 and 370x523px at 1024 — i.e. on the
   supervisor's and the crew lead's landscape tablet, the devices this band
   exists for. It also RE-PAIRED the cards: /company's clean w8|w4 + w6|w6
   reflowed into "Win rate" sitting next to "Maintenance SLA health", two cards
   with nothing to do with each other, which changes what the page says.
   Sending everything to 6 keeps the source order pairing intact (cards 1-2,
   3-4, 5-6 …) whatever spans the template asked for, and no row can be short.
   THE EXCEPTION IS A RAIL. `.w8.cardcol` + `.w4.cardcol` (job_detail's and
   job_completion's packet) is not a row of cards, it is two COLUMNS each
   holding a stack — halving those gives two 370px stacks rather than a
   readable page, so a .cardcol goes full-width and the stacks run in
   sequence. Listed after .w8 so it wins on source order at equal specificity. */
@media (min-width:981px) and (max-width:1169px){
  .cardgrid > *,
  .cardgrid > .w4,
  .cardgrid > .w8{grid-column:span 6;}
  .cardgrid > .cardcol{grid-column:span 12;}
}
@media (min-width:688px) and (max-width:927px){
  .cardgrid > *,
  .cardgrid > .w4,
  .cardgrid > .w8{grid-column:span 6;}
  .cardgrid > .cardcol{grid-column:span 12;}
}
@media (max-width:687px){
  .cardgrid > *,
  .cardgrid > .w4,
  .cardgrid > .w6,
  .cardgrid > .w8{grid-column:span 12;}
}
/* ---- .autofill — THE EXCEPTION BOARD. A card grid whose card COUNT and card
   HEIGHTS are both decided at runtime, so no fixed track grid can lay it out
   without holes. The dashboard renders one card per exception queue the
   signed-in role can see: 9 cards for admin, 6 supervisor, 5 crew_lead, 3
   office — and each card holds between 0 and 5 rows of whatever is actually
   wrong today. Two separate defects came out of that on a 12-track grid:
     1. RAGGED LAST ROW. Only a count divisible by BOTH ladder steps (3 and 2)
        is orphan-free at both, and 6 is the only such count the app ships.
        Measured: crew_lead 3 + 2 at 1440 with 381px of dead track beside the
        last row — on the TABLET role's own dashboard — and admin, office,
        crew_lead and owner_exec each stranding one card alone on a row at
        1024. A lone card in a 12-track row reads as a rendering fault.
     2. HOLES INSIDE THE CARDS. The grid is align-items:stretch, so every card
        in a row is as tall as the tallest. Measured at 1440: "Maintenance SLA"
        painted 133px inside a 371px card next to "Expiries" at 350px — 238px
        of empty card, 64% of the row. Repeated on every row and every role.
        align-items:start was tried and is worse: the cards shrink but the GRID
        ROW does not, so the holes just move outside the cards and the board
        reads as disconnected.
   Multi-column solves both at once and is the right model for this content: a
   set of independent, self-contained cards of unequal length. The browser
   balances the columns, so there is no last row to strand and no stretched
   card to hollow out — measured after: void 21px (the card's own padding) on
   every card at every role, and the admin board got ~200px shorter.
   Reading order becomes column-major (down, then across). That is acceptable
   HERE and only here, because the cards are peers — each is a separate queue
   with its own heading — and python already emits them in priority order, so
   the highest-priority queues land at the top of the leftmost column.
   /company deliberately does NOT get this class: its cards are PAIRED (a chart
   beside the measure that reads with it) and column-major flow would break the
   pairs. Steps match the card ladder above, for the same container reason. */
.cardgrid.autofill{display:block;column-count:3;column-gap:var(--gutter);}
/* margin-bottom is the multi-column stand-in for the grid gap; it must beat
   `.cardgrid .card + .card{margin-top:0}`, which is also (0,3,0) — hence the
   source order. break-inside keeps a card from being split down a column. */
.cardgrid.autofill > .card{break-inside:avoid;margin:0 0 var(--gutter);}
/* the "no cards for your role" placeholder, and anything else asking for the
   full measure, spans the columns instead of sitting in the first one */
.cardgrid.autofill > .w12{column-span:all;}
@media (min-width:981px) and (max-width:1169px),(min-width:688px) and (max-width:927px){
  .cardgrid.autofill{column-count:2;}
}
@media (max-width:687px){.cardgrid.autofill{column-count:1;}}
/* A COLUMN of cards inside the 12-track grid (the job packet's w8/w4 rails,
   the completion tab). These were built with an inline
   `style="display:flex;flex-direction:column;gap:16px"`, so the vertical
   rhythm inside a column (16px) did not match the gutter between the columns
   (14px) — two cards side by side sat 2px out of step with the pair below
   them. One class, one gap, same number as the grid. */
.cardcol{display:flex;flex-direction:column;gap:14px;min-width:0;}
.cardcol > .card{margin-top:0;}

/* ---- dashboard widget chrome (inside the card grid) ---- */
.cardgrid .wchead{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;gap:8px;}
.cardgrid .wchead a{font-size:var(--fs-4);color:var(--brand-ink);font-weight:600;text-decoration:none;}
.cardgrid .wchead a:hover{text-decoration:underline;}
/* "Open budgets →" on its own line under the card title (never wraps into a sliver) */
.cardgrid .wcmore{display:block;font-size:var(--fs-4);color:var(--brand-ink);font-weight:600;text-decoration:none;margin:-6px 0 10px;}
.cardgrid .wcmore:hover{text-decoration:underline;}
/* ONE divider per row boundary, driven by SIBLING position, never by
   `:first-of-type`. `:first-of-type` counts every <div> sibling, and the first
   div in an exception card is .wchead (and the last, when card.more is set, is
   .xmore) — so the reset never matched a .wrow and the dividers came out
   ragged: a rule above the first row and a doubled 2px rule at the footer. */
.cardgrid .wrow{display:flex;align-items:center;gap:10px;padding:9px 0;font-size:var(--fs-5);}
.cardgrid .wrow + .wrow,
.cardgrid .wcmore + .wrow{border-top:1px solid var(--line);}
.cardgrid .wrow time{font-variant-numeric:tabular-nums;color:var(--ink2);font-weight:600;min-width:92px;font-size:var(--fs-5);flex:none;}
.cardgrid .wrow .who{flex:1 1 auto;min-width:0;}
.cardgrid .wrow .who b{font-weight:600;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.cardgrid .wrow .who span{display:block;color:var(--ink3);font-size:var(--fs-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.cardgrid .wrow .pill{margin-left:auto;flex:none;white-space:nowrap;}
/* status pill abbreviation at phone widths ("Awaiting Approval" → "Approval") */
.cardgrid .p-abbr{display:none;}
@media (max-width:480px){
  .cardgrid .p-full{display:none;}
  .cardgrid .p-abbr{display:inline;}
}

/* forms library: keep category chip pinned so wrapped titles don't stagger chips */
.fcard{display:flex;flex-direction:column;}
.fcard .cat{margin-top:auto;}

/* ============================ overlays ============================ */
/* toast notifications — stacked, animated, top-right */
.toaststack{position:fixed;top:16px;right:16px;z-index:var(--z-toast);display:flex;flex-direction:column;gap:10px;max-width:min(380px,calc(100vw - 32px));}
.toast{display:flex;align-items:flex-start;gap:10px;background:var(--panel);color:var(--ink);border:1px solid var(--line);border-left:4px solid var(--brand);border-radius:var(--radius);padding:12px 12px 12px 14px;font-size:var(--fs-6);box-shadow:var(--shadow-overlay);opacity:0;transform:translateX(16px);transition:opacity .24s ease,transform .24s ease;}
.toast.in{opacity:1;transform:none;}
.toast.gone{opacity:0;transform:translateX(16px);}
.toast .ti{flex:none;display:flex;margin-top:1px;}
.toast .ti svg{width:16px;height:16px;fill:none;stroke-width:2.4;stroke-linecap:round;stroke-linejoin:round;}
.toast.ok{border-left-color:var(--brand);} .toast.ok .ti svg{stroke:var(--brand-ink);}
.toast.err{border-left-color:var(--danger);} .toast.err .ti svg{stroke:var(--danger);}
.toast.warn{border-left-color:var(--amber);} .toast.warn .ti svg{stroke:var(--amber-ink);}
.toast .tmsg{flex:1;line-height:1.4;font-weight:600;}
/* 17px is the ONE deliberate un-tokenised size in this file: it sizes the ×
   GLYPH, not type. Folding it to a type token would tie the dismiss affordance
   to the body scale, which is not what it is. Documented so the gate's
   font-size check can allow-list exactly this one. */
.toast .tx{flex:none;border:none;background:none;color:var(--ink3);cursor:pointer;font-size:17px;line-height:1;padding:0 2px;}
.toast .tx:hover{color:var(--ink);}
@media (prefers-reduced-motion:reduce){.toast{transition:opacity .2s ease;transform:none;}.toast.gone{transform:none;}}
@media (max-width:520px){.toaststack{left:16px;right:16px;max-width:none;}}

/* branded confirm dialog — replaces native browser confirm(); shares the
   toast design language (panel card, amber accent bar, same shadow/radius) */
.confirmveil{position:fixed;inset:0;z-index:var(--z-confirm);background:rgba(0,19,31,.5);backdrop-filter:blur(2px);
  display:flex;align-items:center;justify-content:center;padding:20px;opacity:0;transition:opacity .18s ease;}
.confirmveil.in{opacity:1;}
.confirmbox{display:flex;align-items:flex-start;gap:11px;background:var(--panel);color:var(--ink);
  border:1px solid var(--line);border-left:4px solid var(--amber);border-radius:var(--radius);
  padding:18px 18px 16px 18px;font-size:var(--fs-7);max-width:min(420px,92vw);width:100%;
  box-shadow:var(--shadow-overlay);transform:translateY(8px) scale(.98);transition:transform .18s ease;}
.confirmveil.in .confirmbox{transform:none;}
.confirmbox .ci{flex:none;display:flex;margin-top:1px;}
.confirmbox .ci svg{width:17px;height:17px;fill:none;stroke:var(--amber-ink);stroke-width:2.4;stroke-linecap:round;stroke-linejoin:round;}
.confirmbox .cbody{flex:1;min-width:0;}
.confirmbox .cmsg{line-height:1.5;font-weight:600;}
.confirmbox .cbtns{display:flex;justify-content:flex-end;gap:8px;margin-top:14px;}
@media (prefers-reduced-motion:reduce){.confirmveil,.confirmbox{transition:opacity .15s ease;transform:none;}}

/* ---- THE modal veil. One backdrop, one radius, one elevation. ----
   Four dialog treatments used to ship: .confirmveil above, plus byte-identical
   copies of .mvveil/.mvbox/.mv-title/.mv-gates/.mvbox .btns in BOTH
   job_detail.html and pipeline_board.html, plus _schedule_tray.html's
   .schedveil — a third copy of the same rgba(0,15,25,.45) backdrop.
   The markup contract is shared with board.js, so the class names stay;
   only the duplication goes. `.schedveil` is the same veil under another name. */
.mvveil,.schedveil{position:fixed;inset:0;z-index:var(--z-overlay);background:rgba(0,15,25,.45);
  display:grid;place-items:center;padding:16px;opacity:0;transition:opacity .15s ease;}
.mvveil.in,.schedveil.in{opacity:1;}
.mvbox{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow-overlay);padding:20px;width:min(440px,100%);}
.mv-title{font-weight:700;font-size:var(--fs-8);margin-bottom:10px;}
.mv-gates{background:var(--amber-tint);color:var(--amber-ink);border:1px solid var(--amber-line);border-radius:var(--radius-sm);padding:10px 14px 10px 10px;font-size:var(--fs-5);font-weight:600;margin-bottom:10px;}
.mv-gates ul{margin:0;padding-left:18px;}
/* consequence line for moves that do more than change the status (Lost
   releases bookings; Reopen does not restore them) — board.js fills it from
   the trigger's data-move-note */
.mv-note{background:var(--panel2);color:var(--ink2);border:1px solid var(--line);border-radius:var(--radius-sm);padding:10px 12px;font-size:var(--fs-5);margin-bottom:10px;}
.mvbox .btns{display:flex;gap:10px;justify-content:flex-end;margin-top:14px;}
/* image lightbox — ONE layer. job_detail had it at 400 and receipts_inbox at
   90, one component name two layers 310 apart. */
.lightbox{position:fixed;inset:0;z-index:var(--z-overlay);background:rgba(0,15,25,.82);display:grid;place-items:center;padding:20px;cursor:zoom-out;}

/* The floating feedback pill is `position:fixed` bottom-right at --z-fab, i.e.
   above the sticky topbar but BELOW any docked surface. It still shares a
   corner with the quote slide-over, whose scrolling rate picker puts the rate
   figure exactly there, so it stands down while a slide-over is open — the
   same fix _feedback.html already applies for the tablet touch bar. */
body:has(.slideover.open) .fb-launch{display:none;}

/* ===================================================================
   OPT-IN PATTERNS. Adding one of these classes to a template is always an
   explicit act, so they are documented offers rather than chassis.

   ADOPTION, re-counted 2026-07-31 (the previous counts here were stale in
   BOTH directions, which is worse than no counts — they claimed everything
   below had "0 adopters" while .filter-disclosure had 5, and separately
   under-counted .scroll-x):
     .scroll-x           6 templates
     .filter-disclosure  5 templates
     .scroll-x-fade      2 templates (both schedule boards)
     .drow               0 — KEPT, see below. This is the only unadopted
                         pattern left, and it is a migration target.
   Everything else that was unadopted at this pass has been DELETED rather
   than carried forward again. verify_ui.py::check_dead_css now enforces this
   automatically, so the next reader gets a machine-checked answer instead of
   a hand census that goes stale.
   =================================================================== */

/* .scroll-x — horizontal scroll container for wide content. (ADOPTED: 6.)
   Wrap in .scroll-x-fade to get a right-edge fade cue when it actually
   overflows:
   <div class="scroll-x-fade"><div class="scroll-x"> …wide thing… </div></div>

   THE CUE KEYS OFF REAL OVERFLOW, NOT VIEWPORT WIDTH. It used to be
   suppressed by `@media (min-width:981px)`, on the theory that the fade was
   "a cue for narrow screens" — but BOTH adopters are the schedule boards
   (schedule_month/schedule_week), and the month board sets its own
   min-width of ncols*34px = 1054px for a 31-day month. Inside the 1180px
   pagewrap minus the 260px tray rail its visible port is ~890px, so it
   overflows on a 1440px DESKTOP, which is precisely where the cue was turned
   off. The board was hard-clipped at day 26 with no fade, no shadow and (on
   any OS with overlay scrollbars) no persistent scrollbar: a supervisor
   planning the end of the month saw an empty board and no reason to scroll.
   base.html now sets/clears `.is-over` from a ResizeObserver + scroll
   listener, and clears it at the right-hand end, so the cue is truthful in
   both directions and at every width. No JS -> no cue (fails quiet, never
   lies). */
.scroll-x{overflow-x:auto;-webkit-overflow-scrolling:touch;}
.scroll-x-fade{position:relative;}
.scroll-x-fade.is-over::after{content:"";position:absolute;top:0;right:0;bottom:0;width:28px;pointer-events:none;background:linear-gradient(to right,rgba(0,0,0,0),var(--panel));}

/* .scroll-y-fade / .scroll-y — the SAME contract on the vertical axis, and for
   the same reason. Three surfaces clip a list at a fixed max-height with no
   cue at all: the scheduling tray's `.stray-list` (300px), `.crewpick` (180px)
   and `.schedbox` (92vh). Measured on /schedule?ym=2026-08 at 1440x900 with
   the F5/F6 fixtures, the tray's 4th item is sliced horizontally through the
   middle of a text line and the card's footnote ("7 won jobs waiting for
   dates…") begins directly under the half-glyph — it reads as a rendering
   fault, not a scroll region, and the header count (7) contradicts the 3.5
   items visible, so a supervisor cannot tell whether the list is short or
   scrolled.
   Same JS, same `.is-over` class, same honesty rule: the cue paints only on
   MEASURED overflow and clears at the bottom end, and no JS means no cue.
   `padding-bottom` on the scroller keeps the last item clear of the fade so
   nothing is read through it. */
.scroll-y{overflow-y:auto;-webkit-overflow-scrolling:touch;}
.scroll-y-fade{position:relative;}
.scroll-y-fade.is-over::after{content:"";position:absolute;left:0;right:0;bottom:0;height:26px;pointer-events:none;background:linear-gradient(to bottom,rgba(0,0,0,0),var(--panel));border-bottom-left-radius:inherit;border-bottom-right-radius:inherit;}

/* .drow — generic grid-row "table" chrome; pages set ONLY their own
   grid-template-columns. 0 adopters, and kept anyway: at least 13 templates
   have hand-rolled this identical component under their own names (.rg-row,
   .erow, .aprow, .filerow, .frow, .item-row, .pfrow, .colrow, .jt-row,
   .lane-form-row, .srow, .hrow, .wotable), each re-declaring display:grid,
   gap, padding, border-top, align-items, font-size and the :first-of-type
   reset. The file already carries TWO by-name patch lists (the .rowlink
   affordance and the .is-retired state) that must be edited every time a
   fourteenth page invents a fourteenth row class — proof the system already
   knows these are one component. Migrating them onto .drow deletes ~90 lines
   of template CSS and retires both fragile selector lists. See the migration
   list in the system-pass report. */
.drow{display:grid;gap:var(--gutter);padding:11px 4px;border-top:1px solid var(--line);align-items:center;font-size:var(--fs-6);}
.drow:first-of-type{border-top:none;}
.drow.head{padding:6px 4px;}
.drow .num{text-align:right;font-variant-numeric:tabular-nums;justify-self:end;}
@media (max-width:760px){
  .card:has(.drow){overflow-x:auto;-webkit-overflow-scrolling:touch;}
  .drow{min-width:560px;}
}

/* DELETED at the 2026-07-31 system pass, all with a verified ZERO adopters
   across all 87 templates: .pager (+ a/.off), .note-row/.note-body/.note-meta,
   and the whole .mobile-line-card family (.mlc-hide/.mlc-title/.mlc-sub/
   .mlc-stats). ~35 lines that no page could reach, sitting in the one file
   every contributor reads to learn what the system offers.
   .mobile-line-card in particular was superseded by the horizontal-scroll
   approach the app actually took (.scroll-x / .tablecard), so it documented a
   direction the codebase had already rejected.
   .drow below is deliberately KEPT despite also having 0 adopters — it is the
   canonical answer to a problem 13 pages have each solved badly, so it is a
   migration target, not residue. See the note on it. */

/* .filter-disclosure — collapsible filter bar for phones. Use on a
   <details class="filter-disclosure" open> wrapping the filter form with a
   <summary>Filters</summary>. Ship it open: desktop hides the summary and
   always shows the filters; phones show the summary as a toggle button. */
.filter-disclosure > summary{display:none;list-style:none;cursor:pointer;}
.filter-disclosure > summary::-webkit-details-marker{display:none;}
@media (max-width:767px){
  .filter-disclosure > summary{display:inline-flex;align-items:center;gap:7px;font-size:var(--fs-5);font-weight:600;color:var(--ink2);background:var(--panel2);border:1px solid var(--line);border-radius:var(--radius-sm);padding:7px 13px;margin-bottom:10px;}
  .filter-disclosure > summary::after{content:"▾";font-size:var(--fs-2);color:var(--ink3);}
  .filter-disclosure[open] > summary::after{content:"▴";}
}

/* ============================ end opt-in ============================ */

/* ---- docked touch bar must not cross the sidebar ----
   _touchbar.html pins .touchbar to `left:0;right:0;bottom:0` — full viewport
   width — and activates on `(hover:none) and (pointer:coarse)` REGARDLESS of
   width, while the sidebar only collapses to a hamburger below 980px. Between
   those two thresholds (i.e. every tablet in LANDSCAPE) the bar painted across
   the 222px sidebar column. .appside is `height:calc(100vh - var(--banner-h))`
   with `overflow:hidden` and .sidefoot pinned OUTSIDE .navscroll at the bottom
   of that exactly-viewport-height box — so Settings / My profile / Sign out
   sat underneath the bar and no amount of scrolling recovered them. Measured
   at 1024x768: the bar occupied y=703..768 and elementFromPoint returned the
   BAR over both "My profile" (y=701..729) and "Sign out" (y=731..758). The
   compensating `.appcontent{padding-bottom:96px}` pads only the content
   column, never the sidebar. On a shared crew tablet, being unable to sign out
   is a handover and accountability problem, not a cosmetic one.

   Constraining the bar to the content column also aligns it with the content
   it acts on. `body .touchbar` (0,1,1) is required: the partial's own
   `.touchbar` rule (0,1,0) is injected into the body and therefore wins on
   document order against a bare `.touchbar` here. 222px matches the
   .appframe track exactly. */
@media (min-width:981px){body .touchbar{left:222px;}}

/* sidebar scroller hidden — scroll still works, the bottom fade hints at more */
.navscroll{scrollbar-width:none;-ms-overflow-style:none;}
.navscroll::-webkit-scrollbar{width:0;height:0;display:none;}

/* =====================================================================
   PRINT — screen pages on paper.
   This block did not exist. The ONLY @media print in the whole codebase was
   inside pdf/base_pdf.html, which covers the purpose-built document corpus
   (quotes, handover packs, SWMS) — but nothing covered the ordinary screen
   pages, and Nick Ctrl+P's those for file copies. Two confirmed failures:

   LIGHT — printing /quotes/28 clipped the right-hand columns clean off the
   sheet: UNIT PRICE, GST and AMOUNT AUD simply gone, "lump su" truncated
   mid-word. A quote or invoice draft printed with no money on it.
   DARK  — /credentials and /quotes/28 printed as full-bleed navy sheets with
   white text: a toner-destroying, illegible page, with the app chrome
   (hamburger, STAGING badge, notification bell, avatar, theme toggle) printed
   alongside it.

   RE-DECLARING THE LIGHT TOKENS IS THE LOAD-BEARING PART. Dark is opt-in via
   a [data-theme] attribute on <html>, so it survives into the print
   stylesheet unless the tokens are explicitly reset here. Same bug class as
   the invisible-signature one, and it was unguarded for every screen page.
   verify_ui.py::check_print_css asserts both this block and the light re-
   declaration keep existing.
   ===================================================================== */
@media print{
  /* the whole light palette, restated, so a dark-theme session cannot reach
     paper. Kept in sync with :root above by check_print_css. */
  :root,:root[data-theme="dark"],:root[data-theme="light"]{
    color-scheme:light;
    --ground:#fff;--panel:#fff;--panel2:#F1F5F8;--line:#E2E8EE;--line2:#CBD6DE;
    --ink:#001F33;--ink2:#334155;--ink3:#616F86;
    --brand:#00AFEF;--brand-deep:#008CCD;--brand-ink:#00719C;
    --brand-tint:#E5F6FD;--brand-tint2:#CFEEFB;
    --navy:#001F33;--accent:#6EC1E4;--on-brand:#001F33;
    --pill-strong:#001F33;--on-pill-strong:#fff;
    --danger:#9E4239;--danger-tint:#F9EBE9;
    --amber:#C88A2E;--amber-tint:#F7EDDA;--amber-line:#E6CFA0;--amber-ink:#8A5A1E;
    --on-amber:#402A08;--on-navy:#fff;--ok:#2E7D4F;
    --shadow:none;--shadow-lift:none;--shadow-overlay:none;--banner-h:0px;
  }
  html,body{background:#fff!important;color:#001F33!important;}
  /* chrome, navigation and anything docked/floating is not part of the
     document — it is the app you happened to print FROM. */
  .apptop,.appside,.touchbar,.fb-launch,.hamb,.envtag,.imp-banner,
  .toaststack,.skiplink,.tabs,.filterbar,.page-head .actions,
  .so-veil,.slideover,.lightbox,.confirmveil{display:none!important;}
  /* the shell is a viewport-height grid — let it become page-height flow */
  .appframe:not([hidden]){display:block!important;min-height:0!important;}
  .appmain:not([hidden]),.appcontent:not([hidden]){display:block!important;padding:0!important;}
  .pagewrap{max-width:none!important;}
  /* tables must fit the sheet, not scroll inside a box that paper does not
     have. This is what clipped the money columns. */
  .tablecard,.card:has(.data-table),.scroll-x{overflow:visible!important;}
  .data-table{width:100%!important;min-width:0!important;font-size:9pt;}
  .data-table th,.data-table td{white-space:normal!important;}
  .hide-sm:not([hidden]){display:revert!important;}
  /* keep a card, a tile and a table row whole across a page break */
  .card,.tile,.drow,.data-table tr{break-inside:avoid;}
  .card{box-shadow:none!important;border-color:#CBD6DE!important;}
  a[href]{text-decoration:none;color:inherit;}
}

/* ==================== search-and-narrow picker (.pk) ====================
   Operator, 2026-08-02: "any 'SITE' picker should be a search and narrow.
   there are too many sites. same with clients."  301 sites and 84 accounts
   cannot live in a <select>, least of all under a thumb.

   The host `.pk` ALWAYS contains the real <select class="pk-fallback"> the
   server rendered. picker.js disables + hides it and appends `.pk-box`; with
   no JS the select is simply a select and this whole block styles nothing but
   the wrapper. That is why there is no `display` rule on .pk-fallback here:
   the `hidden` attribute (playpro.css [hidden]{display:none!important}) is the
   only thing that hides it, so a JS failure cannot leave an invisible field.
   ======================================================================== */
.pk{position:relative;}
.pk-box{position:relative;}
/* The text box IS an input, so it inherits the chassis field styling and the
   coarse touch floor for free. Only the trailing gutter is ours (clear ×). */
.pk-q{padding-right:34px;}
.pk-clear{position:absolute;top:0;right:0;height:100%;width:32px;display:flex;
  align-items:center;justify-content:center;background:none;border:0;padding:0;
  cursor:pointer;color:var(--ink3);font-size:17px;line-height:1;border-radius:var(--radius-sm);}
.pk-clear:hover{color:var(--ink);}
.pk-clear:focus-visible{outline:2px solid var(--brand-deep);outline-offset:-2px;}
.pk-list{position:absolute;z-index:var(--z-menu);top:calc(100% + 3px);left:0;right:0;
  margin:0;padding:4px;list-style:none;max-height:290px;overflow-y:auto;
  background:var(--panel);border:1px solid var(--line2);border-radius:var(--radius-sm);
  box-shadow:0 10px 26px rgba(0,31,51,.16);}
.pk-list.busy{opacity:.62;}
.pk-opt{display:block;padding:7px 10px;border-radius:var(--radius-xs);cursor:pointer;}
/* The active row is marked by BOTH a tinted band and a left rule — a tint
   alone is a colour-only signal, and --brand-tint is a 1.05:1 change. */
.pk-opt.on{background:var(--brand-tint);box-shadow:inset 3px 0 0 var(--brand-deep);}
.pk-lab{display:block;font-size:var(--fs-7);color:var(--ink);}
.pk-lab b{background:var(--brand-tint2);font-weight:700;}
/* "Name, Suburb" / "Client · OEM principal" — a bare name does not
   disambiguate 301 sites, so every row carries its qualifier. */
.pk-sub{display:block;font-size:var(--fs-4);color:var(--ink2);margin-top:1px;}
.pk-note{display:block;padding:8px 10px;font-size:var(--fs-4);color:var(--ink2);cursor:default;}
.pk-live{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;}
/* filter bars size their own controls; the wrapper must not stretch */
.filterbar .pk,.jobbar .pk{min-width:150px;}
@media (min-width:560px){.filterbar .pk,.jobbar .pk{min-width:190px;}}

/* ---- touch floor ----
   The 48px rule was only enforced inside form_fill/_touchbar; the shell's own
   controls sat at 20-34px, and the hamburger is the ONLY way to reach the nav
   below 980px. On a coarse pointer these get a 44px box (WCAG 2.5.8). Desktop
   is untouched. Add `.tap` to any page-local control that needs the same floor
   (small ⋯ / ⋮ menus, close buttons, picker rows). */
@media (pointer:coarse){
  .btn,.tab,.tap{min-height:44px;}
  .btn,.tab{padding-top:11px;padding-bottom:11px;}
  /* Picker results are the crew's tap targets on a tablet — a 30px row in a
     list of near-identical suburb names is a mis-tap waiting to happen. */
  .pk-opt,.pk-note{min-height:44px;display:flex;flex-direction:column;justify-content:center;}
  .pk-clear{width:44px;}
  .pk-q{padding-right:46px;}
  .pk-list{max-height:52vh;}
  .iconbtn,.tbell,.hamb{width:44px;height:44px;}
  .tap{min-width:44px;display:inline-flex;align-items:center;justify-content:center;}
  input[type=checkbox],input[type=radio]{width:22px;height:22px;}
  /* TEXT ENTRY was the hole in this floor: the block above lifted every
     BUTTON to 44px but left `input,select,textarea` on the base 9px padding
     (~38px), and any page that tightened them went lower still — the job
     alias row (.alias-add, job_detail.css) measured 27px on a coarse pointer.
     Typed fields are the crew's most error-prone targets, so they get the
     same floor. Checkboxes/radios are excluded: they are sized above. */
  input:not([type=checkbox]):not([type=radio]):not([type=hidden]),
  select,textarea{min-height:44px;}

  /* THE SIDEBAR NAV. The comment above is right that the hamburger is the
     only way to reach the nav BELOW 980px — but it left the >=981px case
     unaddressed, and that is a tablet in landscape, which is real crew and
     supervisor hardware. Every nav row measured 197x27px there: 61% of the
     44px floor, full-width, stacked with a 2px gap, so a mis-tap reliably
     lands on the ADJACENT module and navigates away — a full page load to
     undo on a field connection. Widest-reach touch defect in the product:
     every role, every page. The sidebar is 100vh with .navscroll already
     scrollable, so taller rows simply scroll; .sidefoot is unaffected. */
  .appnav a,.appnav .navbtn{min-height:44px;}
  .tsearch{min-height:44px;}

  /* STATUS-BEARING TYPE FLOOR. Sub-12px was carrying the highest-stakes
     content in the product: credential expiry (".pill" at 10px rendering
     "expired 12 Jul 2026"), the schedule conflict marker (.sb-warn), and
     dormant-wake dates (.wake) — i.e. the smallest type on the page held the
     signals that must survive a glance in sunlight on a glossy tablet. On a
     coarse pointer these step up to --fs-4 (11.5px). Decorative chrome
     (.ngroup, .envtag) is deliberately NOT included: it labels the shell, not
     the work. This is a one-token edit only because the type scale exists. */
  .pill,.etag,.sb-warn,.wake{font-size:var(--fs-4);}

  /* SCHEDULE BARS (.sbar) are NOT fixed here, deliberately — see the note in
     the system-pass report. They measure 22x28px on the month board at tablet
     LANDSCAPE (the <=820px week-view redirect only protects portrait), but
     neither obvious fix belongs in this file: an invisible ::after hit
     expander is clipped by .sbar's own `overflow:hidden`, and a min-height
     floor overflows the month grid's 26px `grid-auto-rows` and makes bars
     overlap vertically. Both are template-owned (_schedule_bar.html,
     schedule_month.html). Fixing it from here would ship a rule that silently
     does nothing, which is worse than the defect. */
}
