/* ================================================================
   SETC&amp;S ControlRoom — Design System v4 ("Neo-Industrial Luxury")
   Command-center visual language: deep warm-charcoal surfaces,
   burnished copper primary accent, deep teal secondary, layered
   translucent glass, soft ambient glow, Phosphor iconography.
   Applies globally once linked — supports dark (default) and light
   via html[data-theme]. Self-hosted fonts + icon font only, no
   runtime network calls. Loaded on every interactive screen EXCEPT
   the print-only document templates (print-permit / print-inspection
   / qr-labels), which intentionally stay pinned to the plain light
   stylesheet.
   ================================================================ */
@import url('/icons/phosphor-regular.css');
@import url('/icons/phosphor-fill.css');

@font-face { font-family:'Clash Display'; font-weight:500; font-style:normal; font-display:swap; src:url('/fonts/clash-display-500.woff2') format('woff2'); }
@font-face { font-family:'Clash Display'; font-weight:600; font-style:normal; font-display:swap; src:url('/fonts/clash-display-600.woff2') format('woff2'); }
@font-face { font-family:'Clash Display'; font-weight:700; font-style:normal; font-display:swap; src:url('/fonts/clash-display-700.woff2') format('woff2'); }
@font-face { font-family:'Geist'; font-weight:400; font-style:normal; font-display:swap; src:url('/fonts/geist-sans-400.woff2') format('woff2'); }
@font-face { font-family:'Geist'; font-weight:500; font-style:normal; font-display:swap; src:url('/fonts/geist-sans-500.woff2') format('woff2'); }
@font-face { font-family:'Geist'; font-weight:600; font-style:normal; font-display:swap; src:url('/fonts/geist-sans-600.woff2') format('woff2'); }
@font-face { font-family:'Geist'; font-weight:700; font-style:normal; font-display:swap; src:url('/fonts/geist-sans-700.woff2') format('woff2'); }
@font-face { font-family:'Geist Mono'; font-weight:400; font-style:normal; font-display:swap; src:url('/fonts/geist-mono-400.woff2') format('woff2'); }
@font-face { font-family:'Geist Mono'; font-weight:500; font-style:normal; font-display:swap; src:url('/fonts/geist-mono-500.woff2') format('woff2'); }
@font-face { font-family:'Geist Mono'; font-weight:600; font-style:normal; font-display:swap; src:url('/fonts/geist-mono-600.woff2') format('woff2'); }

/* ── Dark (default) — warm graphite, burnished copper, deep teal ── */
:root, html[data-theme="dark"] {
  /* Warm-charcoal base, lifted from the original near-black so surfaces
     read as deep graphite rather than pure black — softer on the eyes
     while keeping the neo-industrial identity. */
  --void:        #241F18;
  --void-2:      #2D271F;
  --surface-solid: #332D26;
  --surface:     rgba(53,47,39,.66);
  --surface-2:   rgba(62,55,45,.55);
  --elevated:    rgba(69,61,50,.72);
  --hairline:    rgba(255,255,255,.09);
  --hairline-strong: rgba(255,255,255,.22);
  --glass-highlight: rgba(255,255,255,.07);
  --panel-blur:  18px;

  /* Text tiers brightened for real contrast margin, verified against
     WCAG 2.1 formulas rather than by eye — pushed a further notch past
     the first pass, now ~7:1+ for ink-3 against both surfaces. */
  --ink-1:       #F7F3EE;
  --ink-2:       #CFC6B9;
  --ink-3:       #B5AB9C;

  /* Primary accent — burnished copper (brightened for the same reason) */
  --accent:       #DE9862;
  --accent-2:     #E3A66D;
  --accent-strong:#8B5225;
  --accent-soft:  rgba(222,152,98,.16);
  --accent-glow:  rgba(222,152,98,.45);
  --accent-ink:   #221304;
  /* Accent legible on an --accent-soft pill (the active nav item).
     Necessarily per-theme: lighter than --accent here, darker in light. */
  --accent-on-soft: #E3A66D;
  /* The nav rail reads as its own surface rather than another card —
     a shade deeper than the page so it anchors the layout instead of
     dissolving into it. */
  --sidebar-bg: linear-gradient(180deg, #37312A 0%, #2C2620 100%);
  --sidebar-edge: rgba(255,255,255,.07);
  --sb-ink: var(--ink-3);
  --sb-ink-strong: var(--ink-1);
  --sb-hover: var(--surface-2);
  --sb-active-bg: var(--accent-soft);
  --sb-active-ink: var(--accent-on-soft);
  --sb-divider: var(--hairline);

  /* Secondary accent — deep teal */
  --secondary:       #4FADA5;
  --secondary-2:     #5FBDB5;
  --secondary-strong:#16645F;
  --secondary-soft:  rgba(79,173,165,.16);
  --secondary-glow:  rgba(79,173,165,.42);
  --secondary-ink:   #041210;

  /* Status — emerald / amber / crimson */
  --ok:          #3BC384;
  --ok-2:        #6BDCA6;
  --ok-soft:     rgba(59,195,132,.15);
  --ok-glow:     rgba(59,195,132,.42);
  --ok-ink:      #04231E;
  --due:         #EBAE45;
  --due-2:       #F3C46E;
  --due-soft:    rgba(235,174,69,.15);
  --due-glow:    rgba(235,174,69,.42);
  /* Nudged up from #F4707F: as TEXT on the app's darker panels it was
     measuring 4.25-4.45:1 against real pixels — just under AA — on the
     rejected badge and on inline "Delete" links. */
  --crit:        #F7818E;
  --crit-2:      #FF8E9A;
  --crit-soft:   rgba(247,129,142,.17);
  --crit-glow:   rgba(247,129,142,.46);
  /* A filled badge is a different problem from coloured text: --crit is
     tuned to sit ON dark panels, so filling a badge with it and printing
     white on top gives 2.79:1. Count badges use this deeper red instead,
     which carries white at 5.8:1. */
  --crit-solid:  #C2283C;
  --neutral:     #B5AB9C;
  --neutral-soft:rgba(181,171,156,.15);

  --ambient-1: 196,121,61;
  --ambient-2: 62,153,146;
  --ambient-3: 168,140,90;
  --ambient-alpha: .16;

  --grid-line: rgba(255,255,255,.028);
  color-scheme: dark;
}

/* ── Light ("warm parchment") ──
   Still deliberately warm rather than a stark blue-white screen — but a
   PAGE, not a panel. The previous pass pushed the base to #C5BEB1, a
   mid-taupe, which broke the light theme in two compounding ways:
   cards (#D3CCC1) sat only a few percent above the page behind them, so
   nothing read as elevated; and with so little luminance headroom left,
   every status colour had to be darkened toward black to stay legible,
   which is what produced the muddy look. Even then it did not work —
   measured against real painted pixels, --ink-3 and every status tag
   landed at 3.8-4.5:1, under the 4.5:1 AA minimum.
   The fix is to give the theme its range back: a light page, a near-
   white card well clear of it, and status colours free to be RICHER
   because they no longer fight the background. Every value below is
   verified against --surface-solid and --void with the WCAG 2.1
   formula; the weakest is 5.7:1. */
html[data-theme="light"] {
  --void:        #EDE7DC;
  --void-2:      #E3DCCE;
  --surface-solid: #FCFAF6;
  --surface:     rgba(252,250,246,.82);
  --surface-2:   rgba(246,242,235,.70);
  --elevated:    rgba(255,254,251,.94);
  --hairline:    rgba(28,24,21,.10);
  --hairline-strong: rgba(28,24,21,.20);
  --glass-highlight: rgba(255,255,255,.75);
  --panel-blur:  18px;

  /* Unchanged — these already had margin and now have far more:
     ink-3 goes from 4.0:1 to 7.4:1 purely from the lighter surface. */
  --ink-1:       #1C1815;
  --ink-2:       #453A2F;
  --ink-3:       #584C40;

  --accent:       #8A4A15;
  --accent-2:     #A25C1F;
  --accent-strong:#6E3A0D;
  --accent-soft:  rgba(138,74,21,.12);
  --accent-glow:  rgba(138,74,21,.26);
  --accent-ink:   #FBF0E4;
  --accent-on-soft: #6E3A0D;
  /* Light mode had the rail at --surface-solid, the same near-white as the
     cards sitting next to it, so the whole left edge read as one flat
     sheet. This is a warm stone a step deeper than the page: it separates
     the navigation from the content without turning it into a dark slab,
     and keeps the theme's parchment character. Nav text measures 8.1:1
     on it, the active item 5.8:1. */
  --sidebar-bg:
    radial-gradient(120% 50% at 0% 0%, rgba(222,152,98,.14), transparent 60%),
    linear-gradient(180deg, #2F2822 0%, #262019 55%, #1F1A15 100%);
  --sidebar-edge: rgba(28,24,21,.22);
  --sb-ink: #BCB2A5;
  --sb-ink-strong: #F5EFE6;
  --sb-hover: rgba(255,255,255,.07);
  --sb-active-bg: rgba(222,152,98,.16);
  --sb-active-ink: #E8A971;
  --sb-divider: rgba(255,255,255,.09);

  --secondary:       #10605A;
  --secondary-2:     #14736C;
  --secondary-strong:#0D4E49;
  --secondary-soft:  rgba(16,96,90,.11);
  --secondary-glow:  rgba(16,96,90,.24);
  --secondary-ink:   #EAF6F4;

  --ok:          #096A3A;
  --ok-2:        #0C7B43;
  --ok-soft:     rgba(10,110,61,.10);
  --ok-glow:     rgba(10,110,61,.24);
  --ok-ink:      #E7F6EE;
  --due:         #8A5108;
  --due-2:       #A2620E;
  --due-soft:    rgba(138,81,8,.10);
  --due-glow:    rgba(138,81,8,.24);
  --crit:        #B3122F;
  --crit-2:      #C8203D;
  --crit-soft:   rgba(179,18,47,.10);
  --crit-glow:   rgba(179,18,47,.26);
  /* Filled count badges need a solid fill that carries WHITE text, which
     --crit itself cannot do at the sizes those badges use. */
  --crit-solid:  #A81026;
  --neutral:     #584C40;
  --neutral-soft:rgba(88,76,64,.10);

  /* Pale, not mid-dark. The previous values (168,95,40 / 22,100,95 /
     138,116,74) were dark-theme hues reused verbatim; three of them
     overlapping at 16% turned the light page a muddy grey-green and
     dragged everything sitting directly on the page below AA. */
  --ambient-1: 226,178,124;
  --ambient-2: 132,190,184;
  --ambient-3: 214,196,158;
  --ambient-alpha: .05;

  --grid-line: rgba(28,24,21,.045);
  color-scheme: light;
}

:root {
  --font-display: 'Clash Display', 'Segoe UI', sans-serif;
  --font-body:    'Geist', 'Segoe UI', sans-serif;
  --font-mono:    'Geist Mono', 'Consolas', monospace;

  --ease-engineered: cubic-bezier(.22,.8,.32,1);
  --ease-settle:     cubic-bezier(.34,1.26,.4,1);
  --t-fast: 180ms;
  --t-med:  280ms;
  --t-slow: 620ms;

  /* Remap legacy tokens so every existing page re-skins for free */
  --brand:      var(--accent);
  --brand-dark: var(--accent-strong);
  --navy:       var(--ink-1);
  --success:    var(--ok);
  --warning:    var(--due);
  --danger:     var(--crit);
  --bg:         var(--void);
  --card-bg:    var(--surface);
  --border:     var(--hairline);
  --text:       var(--ink-1);
  --muted:      var(--ink-2);
}

@media (prefers-reduced-motion: reduce) {
  :root { --t-fast:1ms; --t-med:1ms; --t-slow:1ms; }
}

html { background:var(--void); }
body {
  background:
    radial-gradient(1100px 620px at 12% -8%, rgba(var(--ambient-1),.10), transparent 60%),
    radial-gradient(900px 560px at 108% 12%, rgba(var(--ambient-2),.08), transparent 55%),
    linear-gradient(180deg, var(--void), var(--void-2)),
    repeating-linear-gradient(0deg, var(--grid-line) 0 1px, transparent 1px 96px),
    repeating-linear-gradient(90deg, var(--grid-line) 0 1px, transparent 1px 96px);
  background-attachment: fixed;
  font-family: var(--font-body);
  color: var(--ink-1);
  min-height: 100vh;
  transition: background-color var(--t-med) var(--ease-engineered), color var(--t-med) var(--ease-engineered);
}

/* z-index:0, not -1 — a negative z-index here sits behind every normal
   in-flow element on the page (all z-index:auto, which stacks at the "0"
   level regardless of DOM order), which fully hides this canvas behind
   all real content. Confirmed live via direct pixel reads + screenshots
   on the deployed site: -1 rendered nothing visible, 0 rendered
   correctly. DOM order (this canvas is always inserted first in body,
   see initAmbientBackground in api.js) still keeps it behind actual
   content at the same stacking level, and pointer-events:none means it
   never blocks clicks either way. */
#pt-ambient-canvas { position:fixed; inset:0; z-index:0; pointer-events:none; width:100%; height:100%; }

.topbar {
  background: var(--surface);
  color: var(--ink-1);
  backdrop-filter: blur(var(--panel-blur)) saturate(150%);
  -webkit-backdrop-filter: blur(var(--panel-blur)) saturate(150%);
  border-bottom: 1px solid var(--hairline-strong);
  box-shadow: 0 1px 0 var(--glass-highlight) inset, 0 8px 24px -18px rgba(0,0,0,.5);
  position: sticky; top:0; z-index:20;
}
.topbar .sub { color: var(--ink-3); }
.topbar h1 { font-family: var(--font-display); font-weight:600; letter-spacing:.2px; }
.nav-links a { color: var(--ink-2); transition: color var(--t-fast) var(--ease-engineered); }
.nav-links a:hover { color: var(--ink-1); }

.card {
  background: var(--surface);
  backdrop-filter: blur(var(--panel-blur)) saturate(140%);
  -webkit-backdrop-filter: blur(var(--panel-blur)) saturate(140%);
  border: 1px solid var(--hairline);
  border-radius: 14px;
  /* Added a soft brand-tinted ambient glow underneath the existing shadow
     — additive, not a replacement — so every card sitewide reads with a
     touch more depth once cinematic.js's brand tint is applied, without
     changing layout or existing hover/tilt behavior built on top of this. */
  box-shadow: inset 0 1px 0 var(--glass-highlight), 0 16px 40px -28px rgba(0,0,0,.55), 0 30px 60px -40px rgba(var(--brand-tint-rgb, 222,152,98),.18);
  position: relative;
  transition: transform var(--t-med) var(--ease-engineered), box-shadow var(--t-med) var(--ease-engineered), border-color var(--t-med) var(--ease-engineered);
}
.card h2 {
  font-family: var(--font-mono);
  font-size: 11px;
  color: var(--ink-2);
  border-bottom-color: var(--hairline-strong);
  font-weight:600; letter-spacing:.1em; text-transform:uppercase;
  display:flex; align-items:baseline; justify-content:space-between;
}
.card h2::after {
  content: attr(data-idx);
  font-family: var(--font-mono); font-size:10px; letter-spacing:.05em;
  color: var(--ink-3); text-transform:none; font-weight:400;
}
.card h3 { color: var(--ink-2); }
.error-card { background: var(--crit-soft); }

.btn {
  font-family:var(--font-body); background:linear-gradient(155deg, var(--accent-2), var(--accent) 60%); color:var(--accent-ink); font-weight:600;
  border-radius:10px; letter-spacing:.01em;
  box-shadow: inset 0 1px 0 rgba(255,255,255,.4), inset 0 -2px 0 rgba(0,0,0,.15), 0 8px 20px -10px var(--accent-glow);
  transition: transform var(--t-fast) var(--ease-engineered), box-shadow var(--t-fast) var(--ease-engineered), filter var(--t-fast) var(--ease-engineered);
}
.btn:hover { filter:brightness(1.06); transform:translateY(-1px); box-shadow: inset 0 1px 0 rgba(255,255,255,.4), inset 0 -2px 0 rgba(0,0,0,.15), 0 12px 26px -10px var(--accent-glow); }
.btn:active { transform: translateY(1px); box-shadow: inset 0 1px 2px rgba(0,0,0,.3); }
.btn-blue {
  background:linear-gradient(155deg, var(--secondary-2), var(--secondary) 60%); color:var(--secondary-ink); border:none;
  box-shadow: inset 0 1px 0 rgba(255,255,255,.3), inset 0 -2px 0 rgba(0,0,0,.15), 0 8px 20px -10px var(--secondary-glow);
}
.btn-blue:hover { filter:brightness(1.08); transform:translateY(-1px); }
.btn-green { background:linear-gradient(155deg, var(--ok-2), var(--ok) 60%); color:var(--ok-ink); box-shadow:0 8px 20px -10px var(--ok-glow); }
.btn-gray  { background:var(--surface-2); color:var(--ink-1); border:1px solid var(--hairline-strong); }

input, select, textarea {
  background: var(--surface-2); border:1px solid var(--hairline-strong); color: var(--ink-1); font-family:var(--font-body);
}
input::placeholder, textarea::placeholder { color: var(--ink-3); }
input:focus, select:focus, textarea:focus { border-color: var(--accent); box-shadow:0 0 0 3px var(--accent-soft); }
input[type="checkbox"], input[type="radio"] { accent-color: var(--accent); }
label { color: var(--ink-2); }

.field-row { border-bottom-color: var(--hairline); }
.field-label { color: var(--ink-2); }
.field-val { color: var(--ink-1); }

.permit-item { border-bottom-color: var(--hairline); }
.permit-item .id { font-family:var(--font-mono); color:var(--ink-1); letter-spacing:.3px; }
.permit-item .meta { color: var(--ink-2); }

table.list-table th { background:var(--surface-2); color:var(--ink-2); border-bottom-color:var(--hairline-strong); font-family:var(--font-body); }
table.list-table td { border-bottom-color:var(--hairline); font-family:var(--font-body); }
table.list-table td b { font-family:var(--font-mono); font-weight:500; letter-spacing:.2px; }

.kpi { border-radius:3px; border:1px solid var(--hairline); box-shadow:none; }
.kpi .lbl { font-family: var(--font-mono); }
.chain-step .role { color: var(--ink-2); }
.chain-step.pending .role { color: var(--secondary); }
.chain-step.pending { position:relative; }
.chain-step.pending::after {
  content:''; position:absolute; left:50%; bottom:2px; transform:translateX(-50%);
  width:5px; height:5px; border-radius:50%; background:var(--secondary);
  --pulse-color: var(--secondary-glow);
  animation:pulseDot 1.8s var(--ease-engineered) infinite;
}
@media (prefers-reduced-motion: reduce) { .chain-step.pending::after { animation:none; } }
.pcat { border-color: var(--hairline); }
.pcat-title { color: var(--ink-2); border-bottom-color: var(--hairline); }

.pending-highlight { background: var(--due-soft); }
.step { background: var(--surface-2); color: var(--ink-3); }
.step.done   { color: var(--accent-ink); }
.step.active { color: var(--accent-ink); }

/* ── Mono-set technical fields ── */
.mono { font-family: var(--font-mono); letter-spacing:.2px; }

/* ================================================================
   Status language — bracketed mono readouts, not badge pills.
   Reads like an instrument label, not a UI-kit component. Also
   retrofits the existing .badge-* classes used throughout the
   fire-extinguisher register, permit lists and company admin, so
   every screen inherits the new language with no markup changes.
   ================================================================ */
.tag, .badge { display:inline-flex; align-items:center; gap:5px; font-family:var(--font-mono); font-size:11px; font-weight:500; letter-spacing:.02em; line-height:1.4; padding:1px 0; background:none; border-radius:0; }
.tag::before, .badge::before { content:'['; opacity:.55; }
.tag::after, .badge::after  { content:']'; opacity:.55; }
.tag .dot { width:5px; height:5px; flex:none; }
.tag.s-ok, .badge-active, .badge-ok           { color:var(--ok); }
.tag.s-ok .dot                                { background:var(--ok); border-radius:1px; }
.tag.s-due, .badge-pending                    { color:var(--due); }
.tag.s-due .dot                               { background:var(--due); transform:rotate(45deg); }
.tag.s-crit, .badge-expired, .badge-rejected, .badge-overdue { color:var(--crit); font-weight:600; }
.tag.s-crit .dot                              { background:var(--crit); border-radius:50%; animation: pulseDot 1.8s var(--ease-engineered) infinite; }
.tag.s-neutral, .badge-closed                 { color:var(--ink-3); }
.tag.s-neutral .dot                           { background:var(--ink-3); border-radius:50%; }

/* ── SETC&S logo ───────────────────────────────────────────────────
   The logo stands alone — no product name set beside or beneath it. It
   already identifies the company, and pairing it with a wordmark produced
   two competing marks in every rail it was tried in. Where the name is
   needed AS WORDS ("SETC&S ControlRoom") it is written out in text
   elsewhere on the page.

   Alone, it can also be larger, which is what makes it legible at these
   sizes at all: a 3.45:1 lockup shrunk to fit beside a word was the whole
   reason earlier versions read as a smudge.

   LIFT, not glow: a drop-shadow that follows the alpha channel, so it
   traces the hands and letterforms rather than boxing artwork that has no
   box in it. Softer on light, where the same weight would be heavy. */
.setcs-lockup { display: flex; align-items: center; justify-content: center; min-width: 0; }
.setcs-logo {
  display: block; width: auto; height: auto; object-fit: contain;
  filter: drop-shadow(0 3px 6px rgba(0,0,0,.45));
}
:root[data-theme="light"] .setcs-logo { filter: drop-shadow(0 2px 5px rgba(0,0,0,.24)); }

/* Sidebar rail — the full width it can have. */
.pt-sb-brand.setcs-lockup { justify-content: flex-start; width: 100%; }
.pt-sb-logo { height: auto; width: 100%; max-width: 156px; }
/* The sidebar header and the site band are the SAME height, so the
   sidebar's right edge and the band's bottom edge meet as a clean cross
   rather than a near-miss. --pt-topbar-h is the single source for both;
   change it once and they stay aligned.
   The logo shrinks and sits beside the collapse toggle to fit that height
   — it had been on its own wrapped row, which is what made the sidebar
   header taller than the band. */
:root { --pt-topbar-h: 56px; }
.pt-sb-top {
  flex-wrap: nowrap; align-items: center; gap: 8px;
  height: var(--pt-topbar-h); box-sizing: border-box; padding: 0 10px;
}
.pt-sb-brand.setcs-lockup { justify-content: flex-start; width: auto; min-width: 0; flex: 1; }
.pt-sb-logo { width: 100%; max-width: 112px; height: auto; }
/* An icon-only rail cannot carry a 3.45:1 mark legibly. */
.pt-sidebar.collapsed .pt-sb-logo { display: none; }

/* Welcome nav. The nav is 18px padding on a ~34px row, so this is about
   as tall as the mark can go before it starts setting the bar's height
   rather than sitting inside it. */
.setcs-lockup-sm { line-height: 0; }
.setcs-lockup-sm .setcs-logo { height: 34px; }

/* Welcome sign-off — the largest appearance, closing the page. */
.cw-signoff { display: flex; flex-direction: column; align-items: center; gap: 20px; padding: 74px 20px 24px; }
.cw-signoff-logo { width: min(400px, 76vw); height: auto; }

/* Contact Us — quiet outline, so it invites without competing with the
   primary calls to action further up the page. */
.cw-contact {
  display: inline-flex; align-items: center; gap: 8px;
  padding: 11px 22px; border-radius: 999px;
  border: 1px solid var(--hairline-strong); color: var(--ink-1);
  font-size: 13.5px; font-weight: 600; text-decoration: none;
  background: rgba(255,255,255,.03);
  transition: border-color .16s, color .16s, background .16s, transform var(--t-press,.12s) var(--ease-out);
}
.cw-contact:active { transform: scale(.97); }
@media (hover:hover) and (pointer:fine) {
  .cw-contact:hover { border-color: var(--brand-tint); color: var(--brand-tint); background: rgba(255,255,255,.06); }
}
/* In the nav it is a plain link, not the outlined button. Among four
   plain siblings the outline read as the primary action when it is the
   least urgent one on the page. */
.cw-contact-nav { border: 0; background: none; padding: 0; font-size: 13px; font-weight: 400; color: var(--ink-2); }

@media (max-width: 700px) {
  .cw-signoff { padding: 48px 18px 16px; gap: 16px; }
  .cw-signoff-logo { width: min(300px, 82vw); }
  .setcs-lockup-sm .setcs-logo { height: 31px; }
  .pt-sb-logo { max-width: 104px; }
  /* The nav is tight on a phone; the foot of the page carries the same
     button, so this one steps aside rather than squeezing the others. */
  .cw-contact-nav { display: none; }
}

/* ── Every-site comparison band (Home) ─────────────────────────────
   One cell per site. The point is comparison, so the compliance figure is
   the biggest thing in each cell and they line up across a row — a column
   of numbers at the same size and position is read in one sweep, which a
   set of differently-shaped cards is not. */
.site-band-grid {
  display: grid; gap: 12px; margin-top: 12px;
  grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
}
.site-band-cell {
  display: flex; flex-direction: column; align-items: flex-start; gap: 2px;
  padding: 14px 16px; text-align: left; cursor: pointer;
  border: 1px solid var(--hairline-strong); border-radius: 12px;
  background: var(--surface-2); color: var(--ink-1);
  transition: border-color .16s, box-shadow .22s, transform var(--t-press,.12s) var(--ease-out);
}
.site-band-cell:active { transform: scale(.985); }
@media (hover:hover) and (pointer:fine) {
  .site-band-cell:hover { border-color: var(--accent); }
}
.site-band-cell.is-current {
  border-color: var(--brand-tint);
  box-shadow: inset 0 0 0 1px var(--brand-tint), 0 10px 30px -16px var(--brand-tint-glow, rgba(0,0,0,.35));
  cursor: default;
}
.site-band-cell.is-loading { opacity: .55; cursor: progress; }
.site-band-cell:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.sb-name { font-size: 14px; font-weight: 700; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.sb-here {
  font-family: var(--font-mono); font-size: 9px; letter-spacing: .12em; text-transform: uppercase;
  color: var(--brand-tint); border: 1px solid var(--brand-tint); border-radius: 999px; padding: 2px 7px;
}
.sb-loc { font-size: 11px; color: var(--ink-3); font-family: var(--font-mono); }
.sb-pct { font-size: 30px; font-weight: 700; line-height: 1.1; margin-top: 8px; letter-spacing: -.02em; }
.sb-pct.s-ok { color: var(--ok); } .sb-pct.s-due { color: var(--due); } .sb-pct.s-crit { color: var(--crit); }
.sb-sub {
  font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .14em;
  text-transform: uppercase; color: var(--ink-3);
}
.sb-bits { display: flex; flex-wrap: wrap; gap: 4px 12px; margin-top: 10px; }
.sb-bit { font-size: 11px; color: var(--ink-3); }
.sb-bit b { color: var(--ink-2); font-weight: 700; }
.sb-bit.is-crit b { color: var(--crit); }
@media (max-width:700px) {
  .site-band-grid { grid-template-columns: 1fr 1fr; gap: 10px; }
  .site-band-cell { padding: 12px; }
  .sb-pct { font-size: 25px; }
}

/* ── Site bar ──────────────────────────────────────────────────────
   One row of large buttons, one per site, for people who can reach more
   than one. Sticky under the header: on a long register you are often
   deep in a table when you decide to compare the other plant, and a
   control that has scrolled away is a control you re-login instead of
   hunting for.

   Sized to be hit without looking — this gets used on a phone, on a shop
   floor, often with gloves on. */
/* FIXED, not sticky, and every other top-anchored thing is pushed down by
   exactly its height.

   Sticky was wrong twice over. It sits in the flow, so it pushed each
   page's own header down — straight into the alert stack, which is fixed
   at top:64px and does not move. And at z-index 35 it painted over
   .home-utils (fixed, top:16px, z-index 20), hiding the theme toggle,
   Install App and the instant-alerts control on Home.

   --pt-sitebar-h is 0px by default and set from the real measured height
   when the bar mounts, so a page with no bar is completely unaffected and
   nothing has to guess the height. Anything anchored to the top of the
   viewport offsets by it. */
.pt-sitebar {
  position: fixed; top: 0; left: var(--pt-sidebar-w, 0px); right: 0; z-index: 36;
  display: flex; align-items: center; gap: 12px;
  /* Same height as the sidebar header — see --pt-topbar-h. border-box so
     the 1px bottom border is inside it and the two lines land on the same
     pixel row. */
  height: var(--pt-topbar-h); box-sizing: border-box; padding: 0 18px;
  background: var(--surface-solid);
  border-bottom: 1px solid var(--hairline-strong);
  backdrop-filter: blur(var(--panel-blur)) saturate(140%);
  -webkit-backdrop-filter: blur(var(--panel-blur)) saturate(140%);
}
:root { --pt-sitebar-h: 0px; }
/* The bar is out of flow, so the page needs the room back. */
body.pt-has-sitebar { padding-top: var(--pt-sitebar-h); }
.pt-sitebar-lbl {
  display: inline-flex; align-items: center; gap: 6px; flex: none;
  font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .16em;
  text-transform: uppercase; color: var(--ink-3);
}
.pt-sitebar-btns { display: flex; gap: 10px; flex-wrap: wrap; min-width: 0; }
.pt-site-btn {
  display: inline-flex; flex-direction: column; align-items: flex-start; justify-content: center;
  min-height: 44px; padding: 7px 18px;
  border: 1px solid var(--hairline-strong); border-radius: 10px;
  background: var(--surface-2); color: var(--ink-2);
  cursor: pointer; text-align: left; white-space: nowrap;
  transition: border-color .16s, color .16s, background .16s, box-shadow .22s, transform var(--t-press,.12s) var(--ease-out);
}
.pt-site-name { font-size: 14px; font-weight: 700; letter-spacing: -.01em; }
.pt-site-loc  { font-size: 10.5px; color: var(--ink-3); font-family: var(--font-mono); }
.pt-site-btn:active { transform: scale(.97); }
@media (hover:hover) and (pointer:fine) {
  .pt-site-btn:hover { border-color: var(--ink-3); color: var(--ink-1); }
}
/* The selected site GLOWS — it is the one piece of state on this bar, and
   it has to be readable at a glance from arm's length. Border, fill, text
   and a brand-tinted halo all move together rather than relying on colour
   alone, which would fail for anyone who cannot separate the two hues. */
.pt-site-btn.is-on {
  border-color: var(--brand-tint);
  background: var(--brand-tint);
  color: var(--accent-ink);
  box-shadow: 0 0 0 1px var(--brand-tint), 0 10px 30px -10px var(--brand-tint-glow, rgba(0,0,0,.4));
}
.pt-site-btn.is-on .pt-site-loc { color: var(--accent-ink); opacity: .72; }
.pt-site-btn.is-loading { opacity: .6; cursor: progress; }
.pt-sitebar.is-busy .pt-site-btn:not(.is-loading) { opacity: .45; pointer-events: none; }
.pt-site-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

@media (max-width:700px) {
  /* One scrolling strip rather than a wrapping block: with three or four
     sites, wrapping pushes the page's own content below the fold before
     anyone has read a word of it. */
  .pt-sitebar { padding: 8px 12px; gap: 8px; overflow-x: auto; }
  .pt-sitebar-btns { flex-wrap: nowrap; }
  .pt-sitebar-lbl { display: none; }
  .pt-site-btn { padding: 6px 14px; }
  .pt-site-name { font-size: 13px; }
  .pt-sitebar { left: 0; }
}
@media print { .pt-sitebar { display: none !important; } }

/* Touch targets for the chips that are actually LINKS.
   These render about 19px tall, under the 24x24 minimum (WCAG 2.5.8), and
   on a phone they are the tap target for "1 waiting on you" and similar —
   small, close together, and hit with gloves on.

   Only `a.tag` is grown. A plain `span.tag` is a label, not a target, and
   padding it out would push apart readouts that are meant to read as one
   dense line. The chips are typographic marks — [ LIKE THIS ] — so the
   height comes from min-height with the text still centred, leaving the
   type and the bracket glyphs exactly where they were.

   Gated on pointer:coarse rather than viewport width, because the reason
   is the input device: a touchscreen laptop needs this and a narrow
   desktop window does not. */
@media (pointer:coarse) {
  a.tag, a.badge { min-height:24px; padding-block:3px; }
}

@keyframes pulseDot {
  0%   { box-shadow:0 0 0 0 var(--pulse-color, var(--crit-glow)); }
  70%  { box-shadow:0 0 0 6px rgba(0,0,0,0); }
  100% { box-shadow:0 0 0 0 rgba(0,0,0,0); }
}
@media (prefers-reduced-motion: reduce) {
  .tag.s-crit .dot { animation:none; box-shadow:0 0 0 3px rgba(229,72,77,.35); }
}

/* ── Row priority framing for critical/overdue items ── */
.row-critical { position:relative; border-left:3px solid var(--crit); background:linear-gradient(90deg, var(--crit-soft), transparent 60%); }
.row-due      { border-left:3px solid var(--due); }

/* ================================================================
   Instrument cluster — one hero analog dial + a segmented
   readout strip, replacing the generic 4-box KPI grid.
   ================================================================ */
.instrument-panel {
  position:relative; background:var(--surface); border:1px solid var(--hairline-strong);
  backdrop-filter: blur(var(--panel-blur)) saturate(150%); -webkit-backdrop-filter: blur(var(--panel-blur)) saturate(150%);
  border-radius: 18px; padding:22px 26px; margin-bottom:14px; overflow:hidden;
  display:flex; align-items:center; gap:28px; flex-wrap:wrap;
  box-shadow: inset 0 1px 0 var(--glass-highlight), 0 24px 60px -32px rgba(0,0,0,.6), 0 0 0 1px rgba(0,0,0,.02);
  transition: box-shadow var(--t-slow) var(--ease-engineered);
}
.instrument-panel.glow-ok   { box-shadow: inset 0 1px 0 var(--glass-highlight), 0 24px 60px -32px rgba(0,0,0,.6), 0 0 70px -18px var(--ok-glow); }
.instrument-panel.glow-due  { box-shadow: inset 0 1px 0 var(--glass-highlight), 0 24px 60px -32px rgba(0,0,0,.6), 0 0 70px -18px var(--accent-glow); }
.instrument-panel.glow-crit { box-shadow: inset 0 1px 0 var(--glass-highlight), 0 24px 60px -32px rgba(0,0,0,.6), 0 0 90px -16px var(--crit-glow); animation: critBreathe 3.2s ease-in-out infinite; }
@keyframes critBreathe {
  0%,100% { box-shadow: inset 0 1px 0 var(--glass-highlight), 0 24px 60px -32px rgba(0,0,0,.6), 0 0 70px -18px var(--crit-glow); }
  50%     { box-shadow: inset 0 1px 0 var(--glass-highlight), 0 24px 60px -32px rgba(0,0,0,.6), 0 0 100px -12px var(--crit-glow); }
}
.instrument-panel .sweep {
  position:absolute; top:0; bottom:0; left:-45%; width:45%; pointer-events:none;
  background:linear-gradient(90deg, transparent, var(--accent-soft), transparent);
  animation: sweepAcross 950ms var(--ease-engineered) 1;
}
@keyframes sweepAcross { from{ left:-45%; } to { left:100%; } }
@media (prefers-reduced-motion: reduce) {
  .instrument-panel .sweep { display:none; }
  .instrument-panel.glow-crit { animation:none; }
}

/* Corner registration marks — viewfinder / drafting-sheet crop marks */
.crosshair {
  background-image:
    linear-gradient(var(--accent), var(--accent)), linear-gradient(var(--accent), var(--accent)),
    linear-gradient(var(--accent), var(--accent)), linear-gradient(var(--accent), var(--accent)),
    linear-gradient(var(--accent), var(--accent)), linear-gradient(var(--accent), var(--accent)),
    linear-gradient(var(--accent), var(--accent)), linear-gradient(var(--accent), var(--accent));
  background-repeat:no-repeat;
  background-size: 13px 2px, 2px 13px, 13px 2px, 2px 13px, 13px 2px, 2px 13px, 13px 2px, 2px 13px;
  background-position:
    left 9px top 9px, left 9px top 9px,
    right 9px top 9px, right 9px top 9px,
    left 9px bottom 9px, left 9px bottom 9px,
    right 9px bottom 9px, right 9px bottom 9px;
  opacity:.85;
}

.dial-readout { flex:none; text-align:center; font-family:var(--font-mono); }
.dial-readout .num { font-size:30px; font-weight:600; letter-spacing:.01em; }
.dial-readout .lbl { font-size:9px; letter-spacing:.14em; color:var(--ink-3); margin-top:2px; }

/* ── Gradient-treated hero numbers (signature moment #3) ──
   Reserved for the 2–3 most important headline figures only (overall
   compliance %, days-to-expiry). Two-stop, palette-based: charcoal→copper
   in light, bright→copper in dark (both driven by --ink-1 → --accent).
   Static effect — nothing to disable for reduced-motion. Falls back to a
   solid accent colour where background-clip:text isn't supported. */
.gradient-num { color: var(--accent); }
@supports ((-webkit-background-clip: text) or (background-clip: text)) {
  .gradient-num {
    background: linear-gradient(135deg, var(--ink-1) 0%, var(--accent) 100%);
    -webkit-background-clip: text; background-clip: text;
    -webkit-text-fill-color: transparent;
  }
}
.dial-readout .num.gradient-num { font-size:46px; font-weight:700; line-height:1; }

.dial-copy { flex:1; min-width:180px; }
.dial-copy .eyebrow { font-family:var(--font-mono); font-size:10px; letter-spacing:.14em; text-transform:uppercase; color:var(--ink-3); }
.dial-copy .headline { font-family:var(--font-display); font-size:22px; font-weight:600; margin-top:4px; }
.dial-copy .breakdown { margin-top:8px; display:flex; gap:16px; flex-wrap:wrap; }
/* The tags are links now too — each states a number about one module, so
   each goes to where that number can be acted on. */
.dial-copy .breakdown .tag { font-size:12px; text-decoration:none; }
@media (hover: hover) and (pointer: fine) {
  .dial-copy .breakdown a.tag:hover { text-decoration:underline; text-underline-offset:3px; }
}
.dial-copy .breakdown a.tag:focus-visible { outline:2px solid var(--accent); outline-offset:3px; border-radius:3px; }

/* Flexbox + wrap, not CSS grid — wraps into as many rows as needed once
   segments no longer fit at their minimum width, at ANY viewport width,
   not just a single mobile breakpoint (fixes an all-in-one-row flexbox
   that just kept shrinking every segment as more modules were added: 5
   modules was fine, 7 squeezed everything down to an illegible single
   row). Flexbox specifically, not grid: grid's shared column-track count
   across every row left a short last row (e.g. 2 items after a first row
   of 5) with empty, background-only cells stretched to fill the unused
   tracks — looking broken rather than just shorter. Flex-wrap has no
   shared per-row column count, so a short last row's items simply grow
   to fill that row's own width, always reading as complete. */
.readout-strip {
  display:flex; flex-wrap:wrap; gap:1px;
  background:var(--hairline); border:1px solid var(--hairline); border-radius:16px; margin-bottom:14px; overflow:hidden;
  backdrop-filter: blur(var(--panel-blur)) saturate(140%); -webkit-backdrop-filter: blur(var(--panel-blur)) saturate(140%);
  box-shadow: inset 0 1px 0 var(--glass-highlight), 0 16px 40px -30px rgba(0,0,0,.55);
}
/* Anchors, not divs: each read-out is a module's headline number and the
   most obviously clickable thing on the dashboard, but until now clicking
   one did nothing at all. As <a> they get keyboard focus and middle-click
   for free. */
.readout-seg { background:var(--surface); padding:14px 18px 16px; position:relative; flex:1 1 150px;
  display:block; text-decoration:none; color:inherit;
  transition:background var(--t-fast) var(--ease-out); }
@media (hover: hover) and (pointer: fine) {
  .readout-seg:hover { background:var(--surface-2); }
  .readout-seg:hover .eyebrow { color:var(--ink-2); }
}
.readout-seg:focus-visible { outline:2px solid var(--accent); outline-offset:-2px; }
.readout-seg .eyebrow { font-family:var(--font-mono); font-size:9px; letter-spacing:.12em; text-transform:uppercase; color:var(--ink-3); }
.readout-seg .value { font-family:var(--font-display); font-size:28px; font-weight:600; margin-top:5px; line-height:1; }
.readout-seg .track { height:2px; background:var(--hairline-strong); margin-top:9px; overflow:hidden; }
.readout-seg .track i { display:block; height:100%; width:0%; transition:width var(--t-slow) var(--ease-engineered); }
.readout-seg.due .value { color:var(--due); } .readout-seg.due .track i { background:var(--due); }
.readout-seg.ok .value { color:var(--ok); }  .readout-seg.ok .track i  { background:var(--ok); }
.readout-seg.crit .value { color:var(--crit); } .readout-seg.crit .track i { background:var(--crit); }
.readout-seg.neutral .value { color:var(--ink-1); } .readout-seg.neutral .track i { background:var(--ink-3); }

/* ── Technical sub-header strip beneath topbar ── */
.tech-strip {
  display:flex; justify-content:space-between; align-items:center;
  padding:5px 16px; background:var(--void); border-bottom:1px solid var(--hairline);
  font-family:var(--font-mono); font-size:10px; letter-spacing:.08em; color:var(--ink-3);
  background-image: repeating-linear-gradient(90deg, var(--hairline) 0 1px, transparent 1px 8px);
  background-position: 0 100%; background-repeat:repeat-x; background-size: 100% 4px;
}

/* ================================================================
   Theme toggle — sits in .nav-links on every app screen
   ================================================================ */
.theme-toggle, .pt-notif-bell {
  display:inline-flex; align-items:center; justify-content:center;
  width:26px; height:26px; border-radius:8px; border:1px solid var(--hairline-strong);
  background:var(--surface-2); color:var(--ink-2); cursor:pointer; font-size:13px; line-height:1;
  transition: background var(--t-fast) var(--ease-engineered), color var(--t-fast) var(--ease-engineered), border-color var(--t-fast) var(--ease-engineered);
}
.theme-toggle:hover, .pt-notif-bell:hover { color:var(--accent); border-color:var(--accent); }
.theme-toggle i, .pt-notif-bell i { font-size:14px; }

/* ── Instant-alerts toggle — one control, home.html's top utility row,
   for every module's instant alerts (see PT.instantAlertsButtonHtml).
   Deliberately a labelled pill matching .install-app-btn, NOT the
   icon-only bell/theme buttons — an icon-only bell here sat right next
   to the actual notification bell and read as a confusing duplicate.
   The text label is what makes this a distinct control at a glance.
   Three states: off (default, clickable), on (granted), blocked (denied
   by the browser — shown disabled, since only the browser's own site
   settings can undo that, not a click here). */
.pt-alerts-toggle {
  display:inline-flex; align-items:center; gap:6px;
  height:26px; padding:0 10px; border-radius:8px; border:1px solid var(--hairline-strong);
  background:var(--surface-2); color:var(--ink-2); cursor:pointer; font-size:11px; font-family:var(--font-body);
  white-space:nowrap;
  transition: background var(--t-fast) var(--ease-engineered), color var(--t-fast) var(--ease-engineered), border-color var(--t-fast) var(--ease-engineered);
}
.pt-alerts-toggle:hover:not(:disabled) { color:var(--accent); border-color:var(--accent); }
.pt-alerts-toggle i { font-size:13px; }
.pt-alerts-toggle.is-on { color:var(--ok); border-color:var(--ok); }
.pt-alerts-toggle.is-on:hover { color:var(--ok); border-color:var(--ok); }
.pt-alerts-toggle.is-blocked { color:var(--ink-3); cursor:not-allowed; opacity:.6; }

/* ── Notification bell — mounted by PT.mountNotificationBell() ──── */
.pt-notif-wrap { position:relative; display:inline-flex; }
.pt-notif-bell { position:relative; }
.pt-notif-count {
  position:absolute; top:-5px; right:-5px; min-width:16px; height:16px; padding:0 4px;
  border-radius:9px; background:var(--crit-solid); color:#fff; font-family:var(--font-mono);
  font-size:9px; font-weight:700; align-items:center; justify-content:center;
  box-shadow:0 0 0 0 var(--crit-glow); animation:pulseDot 1.8s var(--ease-engineered) infinite;
}
.pt-notif-panel {
  /* z-index above the alert stack (9980), not the 60 it used to carry.
     "See all" on the alert stack opens this panel — and the stack was
     painted on top of it, so the list you had just asked for was both
     unreadable and unclickable. The two also overlap by definition: the
     stack sits under the bell, and the panel drops down from it. */
  /* Anchored to the BELL's measured rect, not to an ancestor.
     It used to be `position:absolute; right:0; top:calc(100% + 8px)`, which
     was correct while the panel lived inside .pt-notif-wrap. The panel was
     then moved to <body> so no ancestor's backdrop-filter could capture it
     (see the note in api.js) — and these two rules were left behind. With
     no positioned ancestor, `100%` resolves against the initial containing
     block, i.e. the VIEWPORT: the panel opened at viewport height + 8px,
     permanently 8px below the bottom of the screen. Clicking the bell on a
     desktop appeared to do nothing at all.
     Both offsets now come from the bell itself, set in setOpen(). */
  position:fixed; top:var(--pt-notif-top, 70px); right:var(--pt-notif-right, 16px);
  width:320px; max-height:400px;
  overflow-y:auto; background:var(--surface-solid); border:1px solid var(--hairline-strong);
  border-radius:10px; box-shadow:0 16px 40px -20px rgba(0,0,0,.5); z-index:9995;
  display:none; text-align:left;
}
.pt-notif-panel.open { display:block; }
/* The bell's wrapper must not trap the panel in a lower stacking context —
   it is position:relative for the panel's own positioning, so give it a
   z-index high enough that the whole widget sits above the stack. */
.pt-notif-wrap { z-index:9995; }
/* And the stack steps aside entirely while the panel is open: they show
   the SAME items, so leaving both up is a duplicate list fighting for the
   same corner of the screen. Set on <body> by the bell's setOpen(). */
body.pt-notif-open .pt-approval-stack { display:none !important; }
/* Same reasoning for the mobile drawer: the stack is fixed-position and was
   painting over the open menu, hiding the nav items the drawer exists to
   show. It steps aside while the drawer is open and comes back on close. */
body.pt-drawer-open .pt-approval-stack { display:none !important; }
.pt-notif-empty { padding:22px 16px; text-align:center; font-size:12px; color:var(--ink-3); }
.pt-notif-item {
  display:flex; align-items:flex-start; gap:10px; padding:10px 14px; text-decoration:none;
  border-bottom:1px solid var(--hairline); color:inherit;
  transition: background var(--t-fast) var(--ease-engineered);
}
.pt-notif-item:last-child { border-bottom:none; }
.pt-notif-item:hover { background:var(--surface-2); }
.pt-notif-item i { font-size:16px; color:var(--accent); margin-top:2px; flex:none; }
.pt-notif-label { font-size:12.5px; font-weight:600; color:var(--ink-1); }
.pt-notif-sub { font-size:11px; color:var(--ink-3); margin-top:1px; }
@media (max-width:480px) { .pt-notif-panel { width:calc(100vw - 32px); right:-8px; } }

/* ── Left sidebar — mounted by PT.mountSidebar() ──────────────────
   Desktop/tablet only (see the 1100px breakpoint below); collapses to
   an icon-only rail, state persisted in localStorage. Pushes page
   content over via body.pt-has-sidebar's margin-left rather than
   requiring every one of the app's ~30 pages to reserve space for it
   individually — same "one shared component, auto-mounted" reasoning
   as the notification bell above. */
/* Widths here are the pre-JS fallback only (avoids a flash of the wrong
   size before PT.mountSidebar's own script runs) — the real, authoritative
   value is set directly as an inline style on every mount/toggle/breakpoint
   change (see api.js). No transition: a `width`/`margin-left` value that
   only ever changes via a class toggled from a click handler got
   permanently stuck at its original value in testing, confirmed via real
   layout geometry, regardless of transition placement or specificity —
   see api.js's applyMetrics for the full account. */
.pt-sidebar {
  position:fixed; top:0; left:0; bottom:0; width:210px; z-index:40;
  display:none; flex-direction:column;
  background:var(--sidebar-bg); border-right:1px solid var(--sidebar-edge);
}
.pt-sidebar.collapsed { width:64px; }
@media (min-width:1100px) { .pt-sidebar { display:flex; } }

/* No transition on margin-left here — deliberately. A `transition:
   margin-left` declaration, on EITHER this conditional rule or an
   unconditional `body` rule, made margin-left get permanently stuck at
   0 in testing: every other diagnostic (selector match, media query
   match, cascade specificity, no competing rule) confirmed the value
   rule below should win outright, and it still silently never took
   effect — main page content stayed flush against the viewport edge,
   rendering underneath the sidebar, until the transition declaration
   was removed entirely, which fixed it immediately and reproducibly.
   Since correctness here matters far more than an animated slide when
   the sidebar toggles, margin-left just snaps instantly instead — the
   sidebar's own `transition: width` above still animates smoothly, so
   the sidebar itself isn't jarring, only the content reflow beside it. */
@media (min-width:1100px) {
  body.pt-has-sidebar { margin-left:210px; }
  body.pt-has-sidebar.pt-sidebar-collapsed { margin-left:64px; }
}

/* Collapse toggle lives in its own top-left row, next to the brand mark —
   same placement Claude's own sidebar uses — rather than the bottom of
   the column, which used to collide with home.html's fixed bottom
   .user-bar (both pinned to the same bottom-left corner). ph-sidebar-simple
   reads as "toggle the sidebar" in either state on its own, so unlike a
   caret it doesn't need to rotate between expanded/collapsed. */
.pt-sb-top { display:flex; align-items:center; gap:6px; padding:12px 10px; border-bottom:1px solid var(--sb-divider); }
.pt-sb-collapse {
  flex:none; width:32px; height:32px; border:none; border-radius:8px; background:transparent; color:var(--sb-ink);
  cursor:pointer; display:flex; align-items:center; justify-content:center; font-size:17px;
  transition: color var(--t-fast) var(--ease-engineered), background var(--t-fast) var(--ease-engineered);
}
.pt-sb-collapse:hover { background:var(--sb-hover); color:var(--sb-ink-strong); }

.pt-sb-brand { display:flex; align-items:center; gap:9px; padding:4px 6px; white-space:nowrap; overflow:hidden; min-width:0; }
/* Superseded by the SETC&S logo rules near the top of this file. That
   one was a 24px SQUARE app icon; the brand logo is 5:1, so 24x24 crushed
   it to an unreadable smudge — and because this rule sits ~450 lines LATER
   it silently won. Kept as a comment rather than left in place, since two
   rules for one element is how it was missed. */
.pt-sb-brand-name { font-family:var(--font-display); font-size:14px; font-weight:600; color:var(--sb-ink-strong); overflow:hidden; text-overflow:ellipsis; }
.pt-sidebar.collapsed .pt-sb-brand { display:none; }
.pt-sidebar.collapsed .pt-sb-top { justify-content:center; }

.pt-sb-items { flex:1; overflow-y:auto; padding:10px 8px; }
/* Pinned below the scrolling list — .pt-sb-items is flex:1, so this sits
   at the bottom of the column whatever the menu's length. */
.pt-sb-foot { flex:none; padding:8px; border-top:1px solid var(--sb-divider); }
.pt-sidebar.collapsed .pt-sb-foot { padding:8px 4px; }
.pt-sb-item {
  display:flex; align-items:center; gap:12px; padding:10px 10px; border-radius:9px;
  color:var(--sb-ink); text-decoration:none; font-size:13px; font-weight:600; white-space:nowrap;
  margin-bottom:2px; transition: background var(--t-fast) var(--ease-engineered), color var(--t-fast) var(--ease-engineered);
}
.pt-sb-item i { font-size:17px; flex:none; width:20px; text-align:center; }
.pt-sb-item:hover { background:var(--sb-hover); color:var(--sb-ink-strong); }
.pt-sb-item.active { background:var(--sb-active-bg); color:var(--sb-active-ink); }
.pt-sidebar.collapsed .pt-sb-label { display:none; }
.pt-sidebar.collapsed .pt-sb-item { justify-content:center; }

/* ── Sidebar sub-menus ──────────────────────────────────────────
   The module link still goes to that module's dashboard; the caret beside
   it opens the pages within, so someone can land directly on "Log a Case"
   rather than a dashboard plus a hunt. The group for the section you are
   already in starts open (see mountSidebar), so your siblings are always
   visible without a click. */
.pt-sb-row { display:flex; align-items:center; gap:2px; }
.pt-sb-row .pt-sb-item { flex:1; min-width:0; }
.pt-sb-caret {
  flex:none; width:26px; height:32px; display:flex; align-items:center; justify-content:center;
  background:none; border:0; padding:0; cursor:pointer; border-radius:7px;
  color:var(--sb-ink); font-size:12px;
  transition: background var(--t-fast) var(--ease-engineered), color var(--t-fast) var(--ease-engineered);
}
.pt-sb-caret:hover { background:var(--sb-hover); color:var(--sb-ink-strong); }
.pt-sb-caret i { transition: transform var(--t-fast) var(--ease-engineered); display:block; }
.pt-sb-group.open .pt-sb-caret i { transform: rotate(180deg); }
/* Collapsed to the 64px rail there is no room for a text sub-menu, and the
   icon rail is the whole point of that mode — hide both caret and list. */
.pt-sidebar.collapsed .pt-sb-caret,
.pt-sidebar.collapsed .pt-sb-sub { display:none; }
.pt-sb-sub {
  display:grid; grid-template-rows:0fr; overflow:hidden;
  transition: grid-template-rows var(--t-med) var(--ease-engineered);
}
.pt-sb-group.open .pt-sb-sub { grid-template-rows:1fr; }
.pt-sb-sub-inner { min-height:0; overflow:hidden; }
.pt-sb-subitem {
  display:block; padding:7px 10px 7px 42px; margin-bottom:1px; border-radius:7px;
  color:var(--sb-ink); text-decoration:none; font-size:12px; font-weight:500; white-space:nowrap;
  overflow:hidden; text-overflow:ellipsis;
  transition: background var(--t-fast) var(--ease-engineered), color var(--t-fast) var(--ease-engineered);
}
.pt-sb-subitem:hover { background:var(--sb-hover); color:var(--sb-ink-strong); }
.pt-sb-subitem.active { color:var(--sb-active-ink); background:var(--sb-active-bg); }
@media (prefers-reduced-motion: reduce) {
  .pt-sb-sub { transition:none; }
  .pt-sb-caret i { transition:none; }
}

/* ── Approval-needed popup ──────────────────────────────────────
   A more insistent alert than the bell (which needs a click to notice) —
   surfaces the instant a poll finds a NEW item awaiting this person's
   approval, while the app is already open. Stacks if more than one
   arrives at once; stays until dismissed or clicked, since a routine
   toast timeout would defeat the point for something urgent. */
/* ── A stack, not a column ────────────────────────────────────────
   Cards sit ON TOP of one another with the rest peeking out behind, so
   twenty waiting items take exactly the same room as three. That is the
   whole reason for the change: as a column it had to be capped or it ran
   off the screen, and everything past the cap disappeared behind an "and
   9 more" card. Depth carries the count instead, and nothing is hidden.

   No max-height and no scrolling any more — there is nothing to scroll,
   because the stack is only ever as tall as its front card. */
.pt-approval-stack {
  /* Offset by the site bar's height when there is one — 0px otherwise, so
     this is unchanged for every single-site install. */
  position:fixed; top:calc(64px + var(--pt-sitebar-h, 0px)); right:16px; z-index:9980;
  width:min(360px, calc(100vw - 32px));
  padding:2px;                    /* room for the cards' shadow */
  outline:none;                   /* focusable for the arrow keys, not outlined */
}
/* The row above the cards: position in the stack, and the two arrows. */
.pt-ap-head {
  display:flex; align-items:center; justify-content:flex-end; gap:4px;
  margin-bottom:6px;
}
.pt-ap-count {
  font-family:var(--font-mono); font-size:10.5px; letter-spacing:.06em;
  color:var(--ink-2); background:var(--surface-solid);
  border:1px solid var(--hairline-strong); border-radius:20px;
  padding:3px 10px; margin-right:auto;
}
.pt-ap-prev, .pt-ap-next {
  width:28px; height:28px; flex:none; display:flex; align-items:center; justify-content:center;
  border-radius:50%; cursor:pointer; font-size:14px;
  background:var(--surface-solid); color:var(--ink-2);
  border:1px solid var(--hairline-strong);
  transition:transform var(--t-press,.12s) var(--ease-out), color .15s, border-color .15s;
}
.pt-ap-prev:active, .pt-ap-next:active { transform:scale(.9); }
@media (hover:hover) and (pointer:fine) {
  .pt-ap-prev:hover, .pt-ap-next:hover { color:var(--accent); border-color:var(--accent); }
}
/* The cards themselves overlap, so they are taken out of flow and the
   wrapper is given the front card's height by layoutStack(). */
.pt-ap-cards { position:relative; }
.pt-ap-cards > .pt-approval-popup {
  position:absolute; top:0; left:0; right:0;
  /* Bottom edge, so a scaled-down card keeps its base where it is and the
     downward nudge shows it below the one in front. */
  transform-origin:50% 100%;
  transition:transform 260ms var(--ease-out), opacity 220ms var(--ease-out);
  /* The slide-in keyframe fights the stack transform for its first 250ms
     and leaves the card in the wrong place until it ends. Position here is
     owned entirely by layoutStack(); arrival is carried by opacity. */
  animation:none;
}
.pt-ap-cards > .pt-approval-popup.is-front { box-shadow:0 24px 60px -22px rgba(0,0,0,.62); }
.pt-approval-popup {
  background:var(--surface-solid); border:1px solid var(--crit); border-left:4px solid var(--crit);
  border-radius:10px; box-shadow:0 20px 50px -20px rgba(0,0,0,.55); padding:14px 16px;
  display:flex; gap:12px; align-items:flex-start; text-align:left;
  animation: ptApprovalIn .25s var(--ease-engineered);
}
@keyframes ptApprovalIn { from { opacity:0; transform:translateX(24px); } to { opacity:1; transform:translateX(0); } }
.pt-approval-popup i.ph-shield-check { font-size:20px; color:var(--crit); flex:none; margin-top:1px; }
.pt-approval-popup .pt-ap-body { flex:1; min-width:0; }
.pt-approval-popup .pt-ap-title { font-size:12px; font-weight:700; color:var(--crit); text-transform:uppercase; letter-spacing:.04em; }
.pt-approval-popup .pt-ap-label { font-size:13px; font-weight:600; color:var(--ink-1); margin-top:3px; }
.pt-approval-popup .pt-ap-sub { font-size:11.5px; color:var(--ink-3); margin-top:2px; }
.pt-approval-popup .pt-ap-actions { display:flex; gap:8px; margin-top:10px; }
.pt-approval-popup .pt-ap-view {
  font-size:11.5px; font-weight:700; color:var(--accent-ink); background:var(--accent); border:none;
  border-radius:6px; padding:6px 12px; cursor:pointer; text-decoration:none;
}
.pt-approval-popup .pt-ap-dismiss {
  font-size:11.5px; color:var(--ink-3); background:none; border:none; cursor:pointer; padding:6px 4px;
}
.pt-approval-popup .pt-ap-dismiss:hover { color:var(--ink-1); }
/* "View all" had NO rule at all, so it rendered as a raw OS button — grey
   fill, outset bevel, square corners, 13.3px default text — inside a card
   where every other control is themed. It was the most conspicuously
   broken thing on the screen at both desktop and phone widths.

   Pushed to the far end because it is the odd one out: View and Dismiss
   act on THIS alert, View all leaves the stack entirely. Grouping it with
   the other two invited hitting it by mistake. */
.pt-approval-popup .pt-ap-seeall {
  font-size:11.5px; font-weight:600; color:var(--ink-2);
  background:none; border:1px solid var(--hairline-strong); border-radius:6px;
  padding:6px 11px; cursor:pointer; margin-left:auto; white-space:nowrap;
  transition:color .15s, border-color .15s;
}
@media (hover:hover) and (pointer:fine) {
  .pt-approval-popup .pt-ap-seeall:hover { color:var(--ink-1); border-color:var(--ink-3); }
}
.pt-approval-popup .pt-ap-seeall:active { transform:scale(.97); }
/* On a phone the cards are the only thing with room, so they are made
   tighter rather than left at desktop proportions — combined with the
   stack (which is only ever as tall as its front card), the page
   underneath stays readable instead of being a wall of alert boxes. */
@media (max-width:700px) {
  /* --pt-stack-top is measured at runtime (api.js) from the bottom of
     whatever fixed or sticky chrome this page actually has. The flat
     58px + site-bar guess was right on Home and wrong everywhere else:
     module pages carry a title block and an action row above the fold, so
     the stack landed straight on top of them. The fallback keeps the old
     behaviour if the measurement has not run yet. */
  /* On a phone the stack docks to the BOTTOM.
     Top-anchoring kept colliding with something on every page, because the
     thing it had to clear was different each time: the site band, then the
     module title, then Home's utility cluster, then Work Permit's action
     row — and most of those are in normal flow, so no amount of measuring
     fixed chrome could find them. Each fix moved the collision rather than
     removing it.
     The bottom of a phone screen has exactly two permanent occupants, the
     burger and the help button, and both already publish their offset as
     --pt-fab-bottom. Sitting above that pair clears every header on every
     page by construction, and puts the cards in easy thumb reach. */
  .pt-approval-stack {
    left:10px; right:10px; width:auto;
    top:auto; bottom:calc(var(--pt-fab-bottom, 20px) + 60px);
  }
  /* Cards grow upward from the dock, so the newest sits closest to the thumb. */
  .pt-approval-stack .pt-ap-cards { flex-direction:column-reverse; }

  /* And the page gives back the room the stack occupies, exactly as it
     already does for the site bar at the top. Docking to the bottom fixed
     the header collisions but an overlay still covers whatever is beneath
     it — Home's stat tiles, Work Permit's "Issue New Work". Hiding a
     primary action behind an alert is not acceptable just because the
     alert can be dismissed. With this, nothing is ever permanently
     covered: the content scrolls clear of it, and when the last card goes
     the padding goes with it. --pt-stack-h is measured after layout. */
  body.pt-has-alerts {
    padding-bottom: calc(var(--pt-stack-h, 0px) + var(--pt-fab-bottom, 20px) + 72px) !important;
  }
}

/* ── Touch targets ─────────────────────────────────────────────────
   Keyed to the INPUT DEVICE, not the window width. A phone in landscape
   is 800px wide and still driven by a fingertip; a narrow desktop window
   is not. Every previous mobile fix here was written as a max-width rule,
   which is why a phone held sideways kept the desktop hit areas.

   A sweep of every logged-in page at 320/360/390/768 found the same
   handful of controls under the 44px floor (WCAG 2.5.5, and Apple's HIG)
   on EVERY page, because they are all the shared header and card
   furniture rather than anything page-specific:

     module header links (Print Sheet, QR Labels, Admin, Home, Logout) 24px
     the bell and the theme toggle                                    26px
     card and toolbar buttons                                      36–38px
     alert card actions, admin row actions                    11–11.5px text

   Fixed here once rather than in thirteen page stylesheets. min-height
   with inline-flex centring keeps the visual size — the padding grows the
   touchable box without making the control look chunky. */
@media (pointer: coarse) {
  /* NOT the top-bar cluster — Install App, Instant Alerts, the bell, the
     theme toggle and the Tour/Admin/Logout links. Growing those to 44px
     made the row visibly unsymmetrical against everything around it and
     pushed it into the tenant's logo. They are small on purpose and sit in
     a row with generous spacing between them, which is what actually makes
     them hittable. Reverted at the owner's instruction.

     Kept for controls that genuinely sit in dense lists, where a mis-tap
     costs a wrong action rather than a near-miss. */
  .card-actions a,
  .card-actions button {
    min-height: 44px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
  }

  /* Buttons that sit at 36–38px across the app. */
  .btn,
  button.btn,
  select,
  textarea,
  /* A bare <input> carries no type attribute, so input[type="text"] never
     matched one — which is most of the inputs in this app. */
  input:not([type="checkbox"]):not([type="radio"]):not([type="hidden"]) {
    min-height: 44px;
  }

  /* The alert card's own actions are NOT here — they live in the single
     touch-target block further down, which is the one that wins. Two
     (pointer:coarse) blocks setting the same properties is how the card
     buttons stayed at 38px while a rule up here claimed 44. */
}

@media (max-width:700px) {

  /* The bell's panel is absolutely positioned to right:0 of its wrapper at
     a fixed 320px. On a phone the bell sits well left of centre, so those
     320px ran off the left edge of the screen and the list was unreadable.
     Across the viewport instead, like every other phone-width overlay. */
  .pt-notif-panel {
    /* --pt-notif-top is the bell's own bottom edge, measured when the
       panel opens (api.js setOpen). It used to read --pt-stack-top, which
       is the alert stack's clearance and has nothing to do with where the
       bell is — so the panel opened over the page header on every module
       page. */
    position:fixed; left:10px; right:10px; top:var(--pt-notif-top, 70px);
    width:auto; max-height:min(58vh, 420px);
  }
  .pt-approval-popup { padding:11px 13px; gap:10px; border-radius:9px; }
  .pt-approval-popup i { font-size:17px; }
  .pt-approval-popup .pt-ap-title { font-size:10.5px; letter-spacing:.05em; }
  .pt-approval-popup .pt-ap-label { font-size:12.5px; margin-top:2px; }
  .pt-approval-popup .pt-ap-sub {
    font-size:11px; margin-top:1px;
    /* One line only. The detail is on the record the View button opens;
       a three-line wrap here just costs another card's worth of screen. */
    overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
  }
  .pt-approval-popup .pt-ap-actions { margin-top:8px; gap:6px; }
  .pt-approval-popup .pt-ap-view { padding:7px 14px; }
  .pt-approval-popup .pt-ap-seeall { padding:7px 10px; }
}

/* Touch targets.
   The arrows were 28x28 — fine for a mouse, too small for a finger, and
   this app is used on a phone at a fire panel, often with gloves on. Sized
   by POINTER rather than by viewport width, because the reason is the input
   device, not the screen: a touchscreen laptop needs this and a narrow
   desktop window does not. 44px is the size at which a target stops being
   a game of chance. */
@media (pointer:coarse) {
  .pt-ap-prev, .pt-ap-next { width:44px; height:44px; font-size:17px; }
  .pt-ap-head { gap:6px; margin-bottom:8px; }
  /* 44px, not 38 — and the label large enough to read at arm's length.
     Dismiss and View all were 11.5px, the smallest type in the app, on
     the one card that interrupts what somebody is doing. This is the
     ONLY place these three are sized; an earlier duplicate block was
     setting them too and losing to this one silently. */
  .pt-approval-popup .pt-ap-view,
  .pt-approval-popup .pt-ap-dismiss,
  .pt-approval-popup .pt-ap-seeall {
    min-height:44px; font-size:12.5px; box-sizing:border-box;
    display:inline-flex; align-items:center; justify-content:center;
  }
}

/* ── Desktop-notification opt-in banner ───────────────────────────
   One-time, dismissible — granting desktop-notification permission
   needs an explicit user click (browsers block silent auto-requests),
   so this is the gesture that makes the approval alert above also
   reach someone who is on a different tab or a different app entirely. */
/* Centered via left/right + auto margins rather than translateX(-50%),
   which fixes two things at once. (1) Being position:fixed it ignores
   body's margin-left, so left:50% centred it on the viewport instead of
   on the content area once the sidebar existed. (2) The centering used
   to fight its own entry animation: ptApprovalIn ends at translateX(0),
   so for the 250ms it runs the banner sat half its own width to the
   right and then snapped left as the animation released the property.
   Keeping centering out of `transform` entirely leaves that property
   free for the animation to own. */
.pt-notif-optin {
  /* Above the approval stack (9980), not below it. This banner is one-time
     and its whole purpose is to be clicked; if the two ever overlap on a
     short viewport, the stack must not be what swallows the click. */
  position:fixed; left:var(--pt-sidebar-w, 0px); right:0; bottom:20px; margin-inline:auto; width:fit-content; z-index:9990;
  display:flex; align-items:center; gap:10px; max-width:min(520px, calc(100% - 32px));
  background:var(--surface-solid); border:1px solid var(--hairline-strong); border-radius:10px;
  box-shadow:0 20px 50px -20px rgba(0,0,0,.55); padding:10px 12px; animation: ptApprovalIn .25s var(--ease-engineered);
}
.pt-notif-optin i { color:var(--accent); font-size:18px; flex:none; }
.pt-notif-optin span { font-size:12px; color:var(--ink-2); flex:1; }
.pt-notif-optin-enable {
  font-size:11.5px; font-weight:700; color:var(--accent-ink); background:var(--accent); border:none;
  border-radius:6px; padding:6px 12px; cursor:pointer; white-space:nowrap;
}
.pt-notif-optin-dismiss {
  font-size:16px; color:var(--ink-3); background:none; border:none; cursor:pointer; padding:2px 4px; line-height:1;
}
.pt-notif-optin-dismiss:hover { color:var(--ink-1); }

/* On a phone this banner is effectively full width, so it ran straight
   under the floating Help button, which owns the bottom-right corner at
   right:20px/bottom:20px. Sitting beside it is not an option at this
   width, so it sits above it instead — and reflows to two rows, with the
   Enable button full width rather than squeezed against the text. */
@media (max-width: 700px) {
  .pt-notif-optin {
    left: 16px; right: 16px; width: auto; max-width: none;
    /* --pt-fab-bottom is published by the Help button itself (api.js), so
       this clears it whether it is sitting at 20px or lifted to 52px above
       a user bar. 48px is the button, 14px the gap. */
    bottom: calc(var(--pt-fab-bottom, 20px) + 48px + 14px + env(safe-area-inset-bottom, 0px));
    flex-wrap: wrap; row-gap: 10px; align-items: flex-start;
    padding: 12px 12px 12px 12px;
  }
  .pt-notif-optin i { margin-top: 2px; }
  .pt-notif-optin span { flex: 1 1 auto; min-width: 0; padding-right: 18px; }
  /* Out of the flex flow entirely so it cannot squash the message. */
  .pt-notif-optin-dismiss { position: absolute; top: 6px; right: 8px; }
  .pt-notif-optin-enable { flex: 1 1 100%; padding: 10px 12px; font-size: 12.5px; }
}

/* ── Guided tour (shared/tour.js) ─────────────────────────────────
   The spotlight is a single element with an enormous outward box-shadow
   rather than four separate dimming panels or an SVG mask: one box to
   move and size, it animates smoothly between steps, and it cannot leave
   a seam at the corners the way four panels do. */
.pt-tour { position: fixed; inset: 0; z-index: 10050; }
.pt-tour-scrim { position: absolute; inset: 0; }
.pt-tour-spot {
  position: fixed; border-radius: 12px; pointer-events: none;
  box-shadow: 0 0 0 9999px rgba(0,0,0,.62), 0 0 0 2px var(--accent), 0 0 34px -4px var(--accent-glow);
  transition: top .32s var(--ease-out), left .32s var(--ease-out),
              width .32s var(--ease-out), height .32s var(--ease-out);
}
:root[data-theme="light"] .pt-tour-spot { box-shadow: 0 0 0 9999px rgba(24,20,16,.45), 0 0 0 2px var(--accent), 0 0 34px -4px var(--accent-glow); }
/* No scrim of its own — the spotlight's shadow IS the scrim, so a card
   with no target still needs one. */
.pt-tour-card.pt-tour-centred ~ .pt-tour-scrim,
.pt-tour-spot[style*="display: none"] + .pt-tour-scrim { background: rgba(0,0,0,.62); }
.pt-tour .pt-tour-scrim { background: transparent; }
.pt-tour:has(.pt-tour-card.pt-tour-centred) .pt-tour-scrim { background: rgba(0,0,0,.62); }
:root[data-theme="light"] .pt-tour:has(.pt-tour-card.pt-tour-centred) .pt-tour-scrim { background: rgba(24,20,16,.45); }

.pt-tour-card {
  position: fixed; z-index: 2; box-sizing: border-box;
  background: var(--surface-solid); border: 1px solid var(--hairline-strong);
  border-radius: 14px; padding: 18px 20px 16px;
  box-shadow: 0 26px 60px -22px rgba(0,0,0,.6);
  max-height: calc(100vh - 32px); overflow-y: auto;
  animation: ptTourIn .26s var(--ease-out);
}
.pt-tour-card.pt-tour-centred {
  top: 50%; left: 50%; transform: translate(-50%,-50%);
  width: min(420px, calc(100vw - 32px));
}
@keyframes ptTourIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
.pt-tour-card.pt-tour-centred { animation: ptTourInCentred .26s var(--ease-out); }
@keyframes ptTourInCentred {
  from { opacity: 0; transform: translate(-50%,-46%); }
  to   { opacity: 1; transform: translate(-50%,-50%); }
}
.pt-tour-leaving { opacity: 0; transition: opacity .18s var(--ease-out); pointer-events: none; }

.pt-tour-step { font-family: var(--font-mono); font-size: 10px; letter-spacing: .1em;
  text-transform: uppercase; color: var(--ink-3); }
.pt-tour-title { font-family: var(--font-display); font-size: 18px; font-weight: 700;
  color: var(--ink-1); margin: 8px 0 8px; line-height: 1.2; }
.pt-tour-body { font-size: 13px; line-height: 1.6; color: var(--ink-2); margin: 0; }
.pt-tour-dots { display: flex; gap: 5px; margin: 16px 0 14px; flex-wrap: wrap; }
.pt-tour-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--hairline-strong); transition: background .2s; }
.pt-tour-dot.done { background: var(--ink-3); }
.pt-tour-dot.on { background: var(--accent); width: 18px; border-radius: 3px; }
.pt-tour-actions { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.pt-tour-nav { display: flex; gap: 8px; margin-left: auto; }
.pt-tour-skip, .pt-tour-back, .pt-tour-next {
  font-family: var(--font-body); font-size: 12.5px; font-weight: 600; cursor: pointer;
  border-radius: 8px; padding: 9px 14px; min-height: 38px; border: 1px solid transparent;
  transition: filter .15s, transform var(--t-press, .12s) var(--ease-out);
}
.pt-tour-skip { background: none; color: var(--ink-3); padding-left: 0; }
.pt-tour-back { background: var(--surface-2); color: var(--ink-2); border-color: var(--hairline-strong); }
.pt-tour-next { background: var(--accent); color: var(--accent-ink); }
.pt-tour-skip:active, .pt-tour-back:active, .pt-tour-next:active { transform: scale(.97); }
@media (hover: hover) and (pointer: fine) {
  .pt-tour-skip:hover { color: var(--ink-1); }
  .pt-tour-next:hover, .pt-tour-back:hover { filter: brightness(1.08); }
}
/* The page must not scroll away underneath the spotlight. */
body.pt-tour-open { overflow: hidden; }

/* Alerts and the notifications opt-in step aside while the tour runs.
   They are fixed overlays, so they land inside the spotlight hole and
   appear at full brightness on top of whatever is being explained — and
   on a phone the opt-in bar sits exactly where the tour card docks. The
   tour has its own steps for the bell and for Instant Alerts, so nothing
   is being hidden that it does not go on to cover properly. */
body.pt-tour-open .pt-approval-stack,
body.pt-tour-open .pt-notif-optin { display: none !important; }

@media (max-width: 600px) {
  /* On a phone there is no room to sit a card beside anything, so it
     docks to the bottom and the spotlight is all the pointing needed. */
  .pt-tour-card, .pt-tour-card.pt-tour-centred {
    top: auto !important; bottom: 14px !important; left: 12px !important;
    right: 12px; width: auto !important; transform: none !important;
    max-height: 62vh; animation: ptTourInPhone .26s var(--ease-out);
  }
  @keyframes ptTourInPhone { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: translateY(0); } }
  .pt-tour-title { font-size: 16.5px; }
  .pt-tour-body { font-size: 12.5px; }
}
@media (prefers-reduced-motion: reduce) {
  .pt-tour-spot { transition: none; }
  .pt-tour-card { animation: none; }
}

/* ── The restart button, beside Logout ────────────────────────── */
.tour-restart {
  background: none; border: none; cursor: pointer; padding: 0;
  font-family: inherit; font-size: inherit; color: inherit;
  display: inline-flex; align-items: center; gap: 6px;
}
@media (hover: hover) and (pointer: fine) { .tour-restart:hover { color: var(--accent); } }

/* ── Tour button ─────────────────────────────────────────────────
   Sits in a page nav beside the theme toggle. Deliberately quiet: it is
   an offer, not an action anybody needs to take twice. */
.pt-tour-btn {
  display:inline-flex; align-items:center; gap:6px;
  height:26px; padding:0 10px; border-radius:8px;
  border:1px solid var(--hairline-strong); background:var(--surface-2);
  color:var(--ink-2); cursor:pointer; font-size:11px; font-family:var(--font-body);
  transition:color .15s, border-color .15s, transform var(--t-press,.12s) var(--ease-out);
}
.pt-tour-btn i { font-size:13px; }
.pt-tour-btn:active { transform:scale(.96); }
@media (hover:hover) and (pointer:fine) {
  .pt-tour-btn:hover { color:var(--accent); border-color:var(--accent); }
}

/* ── Required-field marker ────────────────────────────────────────
   One convention, learnable in a second: a red star means required,
   nothing means optional. Writing "(optional)" after half the labels said
   the same thing in twice the words, and inconsistently — some optional
   fields were marked, some were not, so a plain label told you nothing.
   The legend (PT.reqLegend) states the rule once at the top of the form
   so the star does not have to be guessed at.

   Kept as its own class rather than an inline colour so the marker is
   findable, and so screen readers get the word rather than an asterisk:
   the visible star is aria-hidden and the real text sits beside it. */
.req { color: var(--crit); font-weight: 700; margin-left: 2px; }
/* Visible to a screen reader, not to the eye. */
.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
.req-legend {
  display: flex; align-items: center; gap: 6px;
  font-size: 11.5px; color: var(--ink-3); margin: 0 0 14px;
}
.req-legend .req { margin: 0; }

/* ── Photo picker (PT.photoPickerHtml) ────────────────────────────
   A real button rather than the browser's default file input, because the
   default renders as a tiny "Choose File / no file chosen" that is hard to
   hit with gloves on and gives no clue which of the two it is. Each label
   wraps its own hidden input, so the label IS the hit target. */
.pt-photo-pick { display:flex; flex-wrap:wrap; gap:8px; }
.pt-pick-btn {
  display:inline-flex; align-items:center; gap:7px; cursor:pointer;
  font-size:12px; font-weight:600; font-family:var(--font-body);
  padding:9px 14px; border-radius:8px; min-height:40px;
  background:var(--accent); color:var(--accent-ink); border:1px solid transparent;
  transition: transform var(--t-press, .12s) var(--ease-out, ease), filter .15s;
}
.pt-pick-btn i { font-size:15px; }
/* The second choice is quieter than the first — both are one tap, but the
   camera is what a person standing in front of the fault wants. */
.pt-pick-btn.pt-pick-alt {
  background:var(--surface-2); color:var(--ink-2); border-color:var(--hairline-strong);
}
.pt-pick-btn:active { transform: scale(.97); }
@media (hover: hover) and (pointer: fine) {
  .pt-pick-btn:hover { filter: brightness(1.06); }
  .pt-pick-btn.pt-pick-alt:hover { color:var(--ink-1); }
}
@media (max-width: 420px) {
  /* Side by side they wrap to two cramped lines; full width each reads
     better and gives a bigger target. */
  .pt-pick-btn { flex: 1 1 100%; justify-content:center; }
}

/* ── Install App button — hidden by default; api.js shows it on load
   on every browser (unless already running installed), and PT.installApp()
   uses the native prompt where available or shows manual steps otherwise ── */
.install-app-btn {
  display:none; align-items:center; gap:6px;
  height:26px; padding:0 10px; border-radius:8px; border:1px solid var(--hairline-strong);
  background:var(--surface-2); color:var(--ink-2); cursor:pointer; font-size:11px; font-family:var(--font-body);
  transition: background var(--t-fast) var(--ease-engineered), color var(--t-fast) var(--ease-engineered), border-color var(--t-fast) var(--ease-engineered);
}
.install-app-btn:hover { color:var(--accent); border-color:var(--accent); }
.install-app-btn i { font-size:13px; }

/* ── Phosphor icon alignment in nav/buttons/headings ── */
.nav-links a i, .btn i, .topbar h1 i { vertical-align:-2px; margin-right:3px; }
.topbar h1 i { color:var(--accent); font-size:.9em; }

/* ================================================================
   Progressive-disclosure register row — replaces dense N-column
   tables. One scannable summary line; click to expand full detail.
   Critical/overdue rows get position + elevation priority, not
   just a coloured cell.
   ================================================================ */
.fe-reg-row { border-bottom:1px solid var(--hairline); }
.fe-reg-row:last-child { border-bottom:none; }
.fe-reg-row.row-critical, .fe-reg-row.row-due { border-bottom-color:var(--hairline); margin-bottom:2px; border-radius:2px; }
.fe-reg-summary {
  display:grid; align-items:center; gap:4px 14px; padding:10px 8px; cursor:pointer;
  grid-template-columns: 130px 1.3fr 0.9fr 1.5fr auto;
  grid-template-areas: "id loc type tags actions";
  transition: background var(--t-fast) var(--ease-engineered);
}
.fe-reg-summary:hover { background:var(--surface-2); }
.fe-reg-toggle { display:inline-block; font-size:9px; color:var(--ink-3); transition:transform var(--t-fast) var(--ease-engineered); margin-right:4px; }
.fe-reg-row.open .fe-reg-toggle { transform:rotate(90deg); }
.fe-reg-id { grid-area:id; font-family:var(--font-mono); font-weight:600; color:var(--ink-1); font-size:13px; }
.fe-reg-loc { grid-area:loc; min-width:0; }
.fe-reg-loc .place { font-size:12px; color:var(--ink-1); }
.fe-reg-loc .dept { font-size:10px; color:var(--ink-3); }
.fe-reg-type { grid-area:type; font-size:11px; color:var(--ink-2); }
.fe-reg-tags { grid-area:tags; display:flex; flex-direction:column; gap:2px; }
.fe-reg-actions { grid-area:actions; display:flex; gap:10px; font-size:11px; align-self:start; padding-top:2px; }
.fe-reg-detail {
  display:grid; grid-template-columns:1fr 1fr 1fr; gap:0 24px;
  padding:4px 8px 14px 40px; background:var(--surface-2);
  border-top:1px solid var(--hairline);
}
@media (max-width:760px){
  .fe-reg-detail{ grid-template-columns:1fr 1fr; }
  .fe-reg-summary{
    grid-template-columns: 1fr auto;
    grid-template-areas: "id actions" "loc loc" "type type" "tags tags";
  }
}

/* ================================================================
   QR materialize — a brief assemble-in moment for generated QR
   codes (on-site permit access, FE labels), not an instant pop-in.
   ================================================================ */
.qr-wrap { position:relative; overflow:hidden; border-radius:6px; }
.qr-materialize svg { animation: qrAssemble 480ms var(--ease-settle) both; }
@keyframes qrAssemble { from { opacity:0; transform:scale(.82); } to { opacity:1; transform:scale(1); } }
.qr-wrap.qr-materialize::after {
  content:''; position:absolute; inset:0; pointer-events:none;
  background:linear-gradient(90deg, transparent, var(--accent-soft), transparent);
  animation: sweepAcross 550ms var(--ease-engineered) 1;
}
@media (prefers-reduced-motion: reduce) {
  .qr-materialize svg { animation:none; }
  .qr-wrap.qr-materialize::after { display:none; }
}

/* ================================================================
   Empty state — geometric line-art, brand palette, given a very
   gentle "alive" breathing motion so it reads as a considered
   moment rather than a dead-end.
   ================================================================ */
.empty-instrument { text-align:center; padding:36px 16px; color:var(--ink-3); }
.empty-instrument svg { margin-bottom:10px; opacity:.9; animation: emptyBreathe 4.5s ease-in-out infinite; filter: drop-shadow(0 0 14px var(--accent-soft)); }
@keyframes emptyBreathe {
  0%,100% { transform:translateY(0) scale(1); }
  50%     { transform:translateY(-4px) scale(1.015); }
}
.empty-instrument .title { font-family:var(--font-display); color:var(--ink-2); font-size:14px; font-weight:600; margin-bottom:3px; }
.empty-instrument .hint { font-size:12px; color:var(--ink-3); }
@media (prefers-reduced-motion: reduce) { .empty-instrument svg { animation:none; } }

/* ================================================================
   Card entrance — subtle staggered rise+fade on first render, so a
   freshly-loaded dashboard/list feels assembled rather than dumped.
   Apply .stagger-in to a container; each direct .card / panel child
   is offset by its DOM order automatically.
   ================================================================ */
.stagger-in > * { animation: cardRise 520ms var(--ease-settle) both; }
.stagger-in > *:nth-child(1) { animation-delay: 0ms; }
.stagger-in > *:nth-child(2) { animation-delay: 60ms; }
.stagger-in > *:nth-child(3) { animation-delay: 120ms; }
.stagger-in > *:nth-child(4) { animation-delay: 180ms; }
.stagger-in > *:nth-child(5) { animation-delay: 240ms; }
.stagger-in > *:nth-child(n+6) { animation-delay: 300ms; }
@keyframes cardRise { from { opacity:0; transform:translateY(10px); } to { opacity:1; transform:translateY(0); } }
@media (prefers-reduced-motion: reduce) { .stagger-in > * { animation:none; } }

/* ================================================================
   Print safety net — printed permits/inspections/labels must
   always render on paper-safe light colours regardless of the
   on-screen theme the operator happened to have active.
   ================================================================ */
@media print {
  :root, html[data-theme="dark"], html[data-theme="light"] {
    --void:#fff; --surface:#fff; --surface-2:#f4f6f8; --elevated:#fff;
    --hairline:#e5e7eb; --hairline-strong:#d1d5db;
    --ink-1:#1f2937; --ink-2:#4b5563; --ink-3:#6b7280;
    --ok:#1e7e34; --due:#92400e; --crit:#c00000; --neutral:#6b7280;
  }
  body { background:#fff !important; }
  .card { box-shadow:none; }
  /* Navigation chrome is screen-only. Without this, the three pages that
     print themselves in place (fe/index, fd/index, wp/admin all call
     window.print() on the live page rather than opening a dedicated
     print template) put the whole nav column on the paper and indent
     every page by the sidebar's width.
     !important on margin-left is required, not stylistic: PT.mountSidebar
     sets it as an INLINE style (see applyMetrics in shared/api.js), and
     an inline declaration outranks any normal stylesheet rule, print
     media or not. */
  .pt-sidebar { display:none !important; }
  body.pt-has-sidebar { margin-left:0 !important; }
}

/* ================================================================
   Design System v5 — the interaction layer
   v4 established the *look* (surfaces, palette, instrument language).
   This layer is about how the app feels under a hand: what answers a
   hover, what a keyboard user can see, what the eye tracks while
   scanning a 200-row register. It is deliberately shared rather than
   per-screen — every table, card and field in the product inherits it,
   which is what keeps 30-odd screens reading as one machine.
   Everything below is token-driven, so it retints with the customer's
   brand colour and holds up in both light and dark.
   ================================================================ */

/* ── Keyboard focus ──────────────────────────────────────────────
   style.css kills the native outline (`input:focus { outline:none }`)
   and signals focus with a border-colour change alone — a ~1px hue
   shift that is essentially invisible on these surfaces, and a WCAG
   2.4.7 failure. There was no :focus-visible rule anywhere in the app.
   :focus-visible paints only for keyboard/AT users, so pointer users
   still get the clean look, and anyone tabbing gets an unmissable ring.
   Loaded after style.css, so these win on source order. */
a:focus-visible,
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
summary:focus-visible,
[tabindex]:focus-visible {
  outline: 2px solid var(--brand-tint, var(--accent));
  outline-offset: 2px;
  border-radius: 8px;
}
/* The Restore-from-Backup control is a <label> wrapping a visually
   hidden <input type=file> — the label is what's seen, the input is
   what's focused, so the ring has to hop across. */
label.btn:focus-within {
  outline: 2px solid var(--brand-tint, var(--accent));
  outline-offset: 2px;
}
/* Pointer focus on a field: keep it quiet but not mute — a tinted
   halo reads as "live" without the shouting of a full ring. */
input:focus, select:focus, textarea:focus {
  border-color: var(--brand-tint, var(--accent));
  box-shadow: 0 0 0 3px rgba(var(--brand-tint-rgb, 222,152,98), .14);
}

/* ── Register rows ───────────────────────────────────────────────
   The product is mostly tables: permit lists, extinguisher registers,
   visitor logs, audit trails. They had no hover state at all, so a row
   was impossible to track across a wide viewport — the eye loses the
   line between ID and Status. Hover is applied to `td`, never `tr`, for
   a specific reason: several of these tables emit their header row as
   bare `<tr><th>` with no <thead>, so the parser drops it into tbody.
   Targeting td excludes the header for free, with no markup changes. */
table.list-table th,
table.list-table td { padding: 9px 10px; }
table.list-table tr > td {
  transition: background-color var(--t-fast) var(--ease-engineered),
              box-shadow var(--t-fast) var(--ease-engineered);
}
table.list-table tr:hover > td {
  background-color: rgba(var(--brand-tint-rgb, 222,152,98), .07);
}
/* A leading edge that lights up on the row you're reading — the same
   registration-mark language as .crosshair, at row scale. Uses an inset
   shadow rather than a border because border-collapse:collapse would
   otherwise reflow the whole column by 3px on hover. */
table.list-table tr:hover > td:first-child {
  box-shadow: inset 3px 0 0 var(--brand-tint, var(--accent));
}
/* Critical/overdue rows already carry their own left border and wash
   (.row-critical); let their meaning survive the hover tint. */
table.list-table tr.row-critical:hover > td:first-child { box-shadow: inset 3px 0 0 var(--crit); }
table.list-table tr.row-due:hover      > td:first-child { box-shadow: inset 3px 0 0 var(--due); }

/* ── Stat cards (fire extinguisher / detection dashboards) ───────
   These were the last component in the product defined *outside* the
   design system: fe/index.html and fd/index.html each carried their own
   byte-for-byte identical copy in a local <style> block, still on the
   pre-v4 palette (--navy / --muted) and the old flat 1px drop shadow.
   That local copy is why those two dashboards visibly diverged from the
   rest of the app no matter what theme.css did. Defining them here makes
   the two modules inherit the same glass as every other surface, and
   deletes the duplication.
   The hairline is drawn with an inset shadow rather than a border on
   purpose: each tile carries an inline style="border-color:<status>" to
   colour its top edge, and a `border` shorthand here would let that
   inline colour bleed onto all four sides. border-top stays width-only so
   the status colour keeps arriving from the markup, exactly as before. */
.stat-cards { display:grid; grid-template-columns:repeat(4,1fr); gap:10px; margin-bottom:14px; }
.stat-card {
  background: var(--surface);
  border-top: 3px solid;          /* colour supplied inline, per tile */
  border-radius: 12px;
  padding: 12px 10px;
  text-align: center;
  backdrop-filter: blur(var(--panel-blur)) saturate(140%);
  -webkit-backdrop-filter: blur(var(--panel-blur)) saturate(140%);
  box-shadow: inset 0 0 0 1px var(--hairline),
              inset 0 1px 0 var(--glass-highlight),
              0 16px 40px -30px rgba(0,0,0,.55);
  transition: box-shadow var(--t-fast) var(--ease-engineered);
}
.stat-card:hover {
  box-shadow: inset 0 0 0 1px var(--hairline-strong),
              inset 0 1px 0 var(--glass-highlight),
              0 18px 44px -28px rgba(0,0,0,.6),
              0 26px 60px -40px rgba(var(--brand-tint-rgb, 222,152,98), .26);
}
.stat-num { font-family: var(--font-display); font-size: 22px; font-weight: 600; letter-spacing: -.01em; color: var(--ink-1); line-height: 1.1; }
.stat-lbl { font-family: var(--font-mono); font-size: 9px; color: var(--ink-3); text-transform: uppercase; letter-spacing: .12em; margin-top: 4px; }

/* ── KPI tiles ───────────────────────────────────────────────────
   v4 introduced the instrument cluster (.readout-strip / .readout-seg)
   to replace "the generic 4-box KPI grid" — but it only ever landed on
   home and wp/dashboard. Six screens (fe/index, so/index and the four
   admin pages) still render .kpi tiles, which theme.css barely touched
   (a 3px radius and a hairline). The result is one product speaking two
   visual languages depending on which module you are standing in.
   These tiles get the readout strip's *materials* — the same glass,
   hairline, inset highlight and depth — so the languages converge with
   no markup change on any of those six pages.
   Note the limit: fe/index writes its numerals' size, weight and colour
   inline (style="font-size:22px;font-weight:800;color:..."), and inline
   styles win over any stylesheet. So the typeface can be unified here
   but the scale cannot — closing that last gap means editing markup,
   which is deliberately out of scope for a shared-layer change. */
.kpi {
  background: var(--surface);
  border: 1px solid var(--hairline);
  border-radius: 12px;
  padding: 13px 14px;
  backdrop-filter: blur(var(--panel-blur)) saturate(140%);
  -webkit-backdrop-filter: blur(var(--panel-blur)) saturate(140%);
  box-shadow: inset 0 1px 0 var(--glass-highlight), 0 16px 40px -30px rgba(0,0,0,.55);
  transition: border-color var(--t-fast) var(--ease-engineered),
              box-shadow var(--t-fast) var(--ease-engineered);
}
.kpi:hover {
  border-color: var(--hairline-strong);
  box-shadow: inset 0 1px 0 var(--glass-highlight),
              0 18px 44px -28px rgba(0,0,0,.6),
              0 26px 60px -40px rgba(var(--brand-tint-rgb, 222,152,98), .26);
}
/* Display face for the figure itself — this lands even where the size and
   colour are inline, which is what actually makes the tiles read as one
   family with the readout strip. */
.kpi .num { font-family: var(--font-display); letter-spacing: -.01em; }

/* ── Action rows ─────────────────────────────────────────────────
   .permit-item is the queue row shared by Work Permits, Visitor
   Management and Safety Observations — literally the list of things
   waiting on you, each ending in a button you are meant to press. It
   had no hover response at all, so the most actionable surface in the
   product was also its most inert.
   It gets the same treatment as a register row (tinted wash + a lit
   leading edge) on purpose: a row you can act on should feel identical
   whether it appears in a queue or a table.
   The negative inline margin lets the wash bleed to the card's edge —
   .card is padded 14px/16px — while the matching padding keeps the text
   aligned with the card heading above it. */
.permit-item {
  margin-inline: -16px;
  padding: 11px 16px;
  transition: background-color var(--t-fast) var(--ease-engineered),
              box-shadow var(--t-fast) var(--ease-engineered);
}
.permit-item:hover {
  background-color: rgba(var(--brand-tint-rgb, 222,152,98), .07);
  box-shadow: inset 3px 0 0 var(--brand-tint, var(--accent));
}
/* The row's own action reads as secondary chrome until you are actually
   on the row — then it steps forward to meet you. */
.permit-item .btn-gray { transition: border-color var(--t-fast) var(--ease-engineered), color var(--t-fast) var(--ease-engineered); }
.permit-item:hover .btn-gray { border-color: var(--brand-tint, var(--accent)); color: var(--ink-1); }

/* ── Cards ───────────────────────────────────────────────────────
   Only border-colour and shadow move here. Transform is deliberately
   left alone: cinematic.js's mountCardTilt owns .card's transform for
   the 3D tilt, and animating both would fight for the same property. */
.card {
  transition: border-color var(--t-med) var(--ease-engineered),
              box-shadow var(--t-med) var(--ease-engineered);
}
.card:hover {
  border-color: var(--hairline-strong);
  box-shadow: inset 0 1px 0 var(--glass-highlight),
              0 20px 48px -26px rgba(0,0,0,.6),
              0 34px 70px -40px rgba(var(--brand-tint-rgb, 222,152,98), .30);
}

/* ── Rows that open a record ──────────────────────────────────────
   A table row that navigates must say so before it is clicked: the
   recent-inspections list looked identical to a static report and so
   nobody discovered the full record behind it. */
tr.row-link { cursor: pointer; transition: background var(--t-fast) var(--ease-engineered); }
tr.row-link:hover > td { background: var(--surface-2); }
tr.row-link:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
/* A hint that only appears on hover keeps the resting table calm. */
tr.row-link > td:first-child { position: relative; }
tr.row-link:hover > td:first-child::before {
  content: ""; position: absolute; left: 0; top: 8%; bottom: 8%; width: 2px;
  background: var(--accent); border-radius: 2px;
}

/* ── Text selection ──────────────────────────────────────────────
   Default selection blue is the one un-branded surface left in the app;
   it appears every time someone copies a permit ID. */
::selection {
  background: rgba(var(--brand-tint-rgb, 222,152,98), .30);
  color: var(--ink-1);
}

/* ── Scrollbars ──────────────────────────────────────────────────
   Long registers mean the scrollbar is on screen constantly; the stock
   one is a bright slab against these dark surfaces. */
* { scrollbar-width: thin; scrollbar-color: var(--hairline-strong) transparent; }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
  background: var(--hairline-strong);
  border-radius: 10px;
  border: 2px solid transparent;
  background-clip: padding-box;
  transition: background var(--t-fast) var(--ease-engineered);
}
::-webkit-scrollbar-thumb:hover {
  background: rgba(var(--brand-tint-rgb, 222,152,98), .55);
  background-clip: padding-box;
}
::-webkit-scrollbar-corner { background: transparent; }

@media (prefers-reduced-motion: reduce) {
  table.list-table tr > td, .card, ::-webkit-scrollbar-thumb { transition: none; }
}

/* ── Wide tables on small screens ────────────────────────────────
   Registers and the cross-site pending read-out have more columns than a
   phone has width. Without this the last few pixels of the table are simply
   clipped and unreachable — the page itself does not scroll sideways, so
   there is no way to see them. Letting the CARD scroll (rather than the
   page) keeps the wide content in its own container, which is the same
   convention used everywhere else in this app. */
@media (max-width: 640px) {
  .card { overflow-x: auto; }
}

/* ── Touch ergonomics ─────────────────────────────────────────────
   WCAG 2.5.8 asks for a 24x24 CSS-px minimum target. Measured on a
   390px viewport, several controls sat well under it: the header and
   user-bar links render 15px tall, the admin permission/module
   checkboxes 13x13, and the inline Edit / Retire links in the registers
   20x13 — all fiddly to hit with a thumb and easy to mis-tap into the
   wrong row.
   Scoped to coarse pointers and phone widths so the desktop layout,
   which is tuned for a mouse, is left exactly as it was. */
@media (pointer: coarse), (max-width: 640px) {
  .user-bar a,
  .nav-links a,
  a.no-print,
  .fe-reg-actions a,
  table.list-table td a {
    min-height: 24px;
    display: inline-flex;
    align-items: center;
    padding-block: 4px;
  }
  /* Row actions are short words ("Edit", "Retire") whose text is barely
     20px wide; widen the target without changing how the row reads. */
  .fe-reg-actions a, table.list-table td a { padding-inline: 6px; min-width: 24px; justify-content: center; }
  /* Spacing so two adjacent row actions cannot be hit as one. */
  .fe-reg-actions { display: flex; flex-wrap: wrap; gap: 10px; }

  /* !important is warranted here and nowhere else: these checkboxes carry
     an inline style="width:auto" (added to stop style.css's
     `input { width:100% }` from stretching them full-width), and an inline
     declaration outranks any selector. Without it the height grew to 22px
     while the width stayed at the 13px default. */
  input[type="checkbox"], input[type="radio"] {
    width: 24px !important; height: 24px !important; flex: none;
  }

  /* The small variants measured 23px — one pixel short of the minimum. */
  .btn, .btn-gray { min-height: 28px; }
}

/* ── Mobile / tablet navigation drawer ────────────────────────────
   .pt-sidebar only renders at >=1100px, and its collapse button lives
   inside it — so below that breakpoint the whole navigation tree (every
   module sub-page, the admin deep links) had no way to be opened at all.
   These rules let the same column serve as an off-canvas drawer, so
   phones and tablets get the identical menu without a second
   implementation to keep in sync. The desktop layout is untouched. */
.pt-sb-burger {
  display: none;
  flex: none; width: 36px; height: 36px; margin-right: 8px;
  align-items: center; justify-content: center;
  border: 1px solid var(--hairline-strong); border-radius: 9px;
  background: var(--surface-2); color: var(--ink-1);
  font-size: 18px; cursor: pointer;
  transition: background var(--t-fast) var(--ease-engineered);
}
.pt-sb-burger:hover { background: var(--elevated); }

@media (max-width: 1099px) {
  /* PINNED, not scrolled away.
     The burger lived inside whatever header its page happened to have, so
     it scrolled off with that header — and once it was gone the only way
     to reach the menu on a phone was to scroll all the way back up. The
     one control that gets you off the page has to be reachable from
     anywhere on it.
     Bottom-left rather than top: it is the corner a thumb reaches without
     regripping, and the top strip is already carrying the site bar. */
  .pt-sb-burger {
    display: inline-flex;
    /* Mirrors the help button EXACTLY — same bottom, same size, opposite
       side — so the two read as a matched pair rather than two controls
       that happen to be near the bottom. --pt-fab-bottom is the variable
       the help button already uses to lift itself above Home's fixed
       user-bar; sharing it is what stops the burger sitting on top of the
       footer there. */
    position: fixed; left: 20px; bottom: var(--pt-fab-bottom, 20px);
    width: 48px; height: 48px; margin: 0; z-index: 58;
    border-radius: 14px; font-size: 21px;
    background: var(--surface-solid);
    box-shadow: 0 10px 30px -10px rgba(0,0,0,.5), 0 2px 8px rgba(0,0,0,.25);
    backdrop-filter: blur(var(--panel-blur)) saturate(140%);
    -webkit-backdrop-filter: blur(var(--panel-blur)) saturate(140%);
  }
  /* It is fixed, so it no longer holds a place in the header — reclaim it. */
  .pt-sb-burger + * { margin-left: 0; }
  /* Under the drawer and its scrim, so it cannot be tapped through them. */
  .pt-sidebar.pt-sb-drawer, .pt-sb-scrim { z-index: 60; }
  /* Wins over both `.pt-sidebar { display:none }` and `.collapsed`. */
  .pt-sidebar.pt-sb-drawer {
    display: flex; width: min(288px, 86vw); z-index: 60;
    box-shadow: 0 24px 64px -18px rgba(0,0,0,.55);
    animation: ptDrawerIn .22s var(--ease-engineered);
  }
  .pt-sb-scrim { position: fixed; inset: 0; z-index: 59; background: rgba(0,0,0,.5); }
  @media (prefers-reduced-motion: reduce) {
    .pt-sidebar.pt-sb-drawer { animation: none; }
  }
}
@keyframes ptDrawerIn { from { transform: translateX(-100%); } to { transform: translateX(0); } }

/* The drawer is a nav surface people tap, so its rows follow the same
   24px minimum as everything else on a touch screen. */
@media (max-width: 1099px) and (pointer: coarse) {
  .pt-sidebar.pt-sb-drawer .pt-sb-item,
  .pt-sidebar.pt-sb-drawer .pt-sb-subitem { min-height: 40px; }
}

/* Never on paper. */
@media print { .pt-sb-burger, .pt-sb-scrim { display: none !important; } }

/* ── Header reflow on small screens ───────────────────────────────
   .topbar and .nav-links are both non-wrapping flex rows. On a phone
   the action links (Print Sheet, QR Labels, Admin, Home, Logout) plus
   the bell and theme toggle needed ~372px on their own, which forced
   the layout viewport wider than the device: the browser then shrank
   the WHOLE PAGE to fit, so every page below became a zoomed-out
   version of the desktop layout with correspondingly tiny text.
   Measured at 390px, 7 of 12 screens were being scaled down this way
   — /so/index.html was laid out at 537px. Letting the header wrap
   fixes all of them at the source. */
@media (max-width: 640px) {
  .topbar { flex-wrap: wrap; row-gap: 8px; }
  /* min-width:0 lets a long module title shrink instead of setting a
     floor on the row's width, which is its own source of overflow. */
  .topbar-left { min-width: 0; flex: 1 1 auto; }
  .topbar h1 { font-size: 14px; }
  .nav-links { flex-wrap: wrap; width: 100%; justify-content: flex-start; gap: 8px 14px; }
}

/* A wide register inside a scrolling card still lets its min-content width
   escape through the page containers, which is enough to make the browser
   widen the layout viewport and zoom the whole page out. Pinning the
   containers to the viewport width contains it: the table still scrolls
   inside its own card, exactly as intended. */
@media (max-width: 900px) {
  .container, .container-wide { max-width: 100vw; min-width: 0; }
  /* Fallback only, for tables with no header row to build labels from
     (see PT.stackTables). Tables that CAN be stacked are handled below
     and never need to scroll sideways. */
  /* 641-900px: too wide to want stacked cards, too narrow for some
     registers — these still scroll inside their own box. Below 640px the
     stacked rules further down take over and nothing scrolls sideways. */
  table.list-table, .card table { display: block; width: 100%; overflow-x: auto; }
}

/* ── Registers as stacked cards on a phone ────────────────────────
   Each row becomes a self-contained little record — "Site: Plant A" one
   line, "Overall: 92%" the next — instead of a seven-column row that
   only fits by scrolling sideways or by zooming the whole page out.
   The label text comes from the matching <th> via a data-label written
   by PT.stackTables, so nothing here hardcodes a column name. */
@media (max-width: 640px) {
  table.list-table,
  table.list-table tbody,
  table.list-table tr,
  table.list-table td { display: block; width: auto; }
  table.list-table { border-collapse: separate; border-spacing: 0; }
  /* The header row is redundant once every cell carries its own label.
     Matched by attribute rather than by thead: these tables mostly have
     no thead element, so the header row lives in tbody like any other. */
  table.list-table thead,
  table.list-table tr:has(> th),
  table.list-table tr[data-headrow] { display: none; }

  table.list-table tr {
    border: 1px solid var(--hairline);
    border-radius: 10px;
    padding: 6px 12px;
    margin-bottom: 10px;
    background: var(--surface-2);
  }
  table.list-table tr:last-child { margin-bottom: 0; }

  table.list-table td {
    display: flex; align-items: baseline; justify-content: space-between; gap: 14px;
    border: none; padding: 6px 0; text-align: right;
    border-bottom: 1px solid var(--hairline);
  }
  table.list-table td:last-child { border-bottom: none; }
  table.list-table td::before {
    content: attr(data-label);
    flex: none; text-align: left;
    font-size: 10.5px; font-weight: 600; letter-spacing: .05em; text-transform: uppercase;
    color: var(--ink-3);
  }
  /* An action cell has no header text; without this it renders an empty
     label column and sits oddly to one side. */
  table.list-table td[data-label=""] { justify-content: flex-end; }
  table.list-table td[data-label=""]::before { content: none; }
  /* "Nothing here yet" spans every column — a label would be nonsense. */
  table.list-table td[data-fullrow] { justify-content: center; text-align: center; }
  table.list-table td[data-fullrow]::before { content: none; }
}

/* ══ Dashboard charts ═══════════════════════════════════════════════
   Marks stay thin and the grid recessive so the DATA is the loudest
   thing on the card. Every value is directly labelled and a table view
   sits behind each chart: two of the light-mode hues fall below 3:1 on
   the light surface, so identity must never rest on colour alone. */
.pt-chart-legend { display:flex; flex-wrap:wrap; gap:14px; margin-bottom:12px; font-size:11px; color:var(--ink-2); }
.pt-chart-legend span { display:flex; align-items:center; gap:6px; }
.pt-chart-legend i { width:9px; height:9px; border-radius:2px; flex:none; }
.pt-chart-empty { padding:26px; text-align:center; color:var(--ink-3); font-size:12px; }

.pt-chart-wrap { position:relative; }
.pt-svg { width:100%; height:200px; display:block; overflow:visible; }
.pt-grid { stroke:var(--hairline); stroke-width:1; }
.pt-axis { fill:var(--ink-3); font-size:9px; font-family:var(--font-mono); }
/* A 2px surface ring keeps overlapping dots readable where lines cross. */
.pt-dot { stroke:var(--surface-solid); stroke-width:2; }
.pt-band { cursor:crosshair; }
.pt-band:hover { fill:rgba(var(--brand-tint-rgb,222,152,98),.07); }
.pt-tip {
  position:absolute; transform:translateX(-50%); pointer-events:none; z-index:5;
  background:var(--surface-solid); border:1px solid var(--hairline-strong); border-radius:8px;
  padding:6px 10px; font-size:11px; color:var(--ink-1); white-space:nowrap;
  box-shadow:0 12px 30px -12px rgba(0,0,0,.5);
  /* 125ms: a tooltip that lags the cursor feels broken. */
  transition:opacity 125ms var(--ease-out);
}

.pt-bars { display:flex; flex-direction:column; gap:9px; }
.pt-bar-row { display:grid; grid-template-columns:minmax(78px,26%) 1fr auto; align-items:center; gap:12px; }
.pt-bar-label { font-size:11px; color:var(--ink-2); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.pt-bar-track { height:9px; border-radius:5px; background:var(--surface-2); overflow:hidden; }
.pt-bar-fill {
  display:block; height:100%; border-radius:5px;
  /* Grows on load; ease-out so it arrives fast and settles. */
  transition:width 520ms var(--ease-out);
}
.pt-bar-value { font-family:var(--font-mono); font-size:11.5px; color:var(--ink-1); min-width:26px; text-align:right; }
.pt-bar-row:focus-visible { outline:2px solid var(--accent); outline-offset:3px; border-radius:6px; }

.pt-chart-table { margin-top:14px; }
.pt-chart-table summary { font-size:11px; color:var(--ink-3); cursor:pointer; }
.pt-chart-table summary:hover { color:var(--ink-1); }
.pt-chart-table table { margin-top:8px; }

.pt-ring { position:relative; width:132px; height:132px; margin:0 auto; }
.pt-ring svg { width:100%; height:100%; transform:rotate(-90deg); }
.pt-ring-track { fill:none; stroke:var(--surface-2); stroke-width:9; }
.pt-ring-fill {
  fill:none; stroke-width:9; stroke-linecap:round;
  transition:stroke-dashoffset 720ms var(--ease-out), stroke 200ms ease;
}
.pt-ring-mid { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:2px; }
.pt-ring-mid b { font-size:30px; font-weight:700; line-height:1; letter-spacing:-.02em; }
.pt-ring-mid small { font-size:14px; margin-left:1px; }
.pt-ring-mid span { font-size:10px; color:var(--ink-3); text-transform:uppercase; letter-spacing:.07em; }

/* margin-bottom matches margin-top. .card carries no margin of its own, so
   the band sat flush against whatever followed it — on the Safety
   Observations dashboard the Monthly Report card was welded to the
   analytics row above with no seam at all. Symmetric here rather than a
   top margin on the next card, because both dashboards that use this band
   (so/index.html, fe/index.html) put something directly after it. */
.pt-analytics { display:grid; grid-template-columns:repeat(auto-fit,minmax(300px,1fr)); gap:14px; margin-top:18px; margin-bottom:18px; }
.pt-analytics .card { margin:0; }
@media (max-width:640px) { .pt-analytics { grid-template-columns:1fr; } }

/* ══ Motion ═════════════════════════════════════════════════════════
   Named curves rather than the built-in CSS easings, which are too weak
   to read as intentional. ease-in is deliberately absent: it delays the
   first few frames — exactly when the eye is on the element — so the
   same duration feels slower than ease-out. */
:root {
  --ease-out:    cubic-bezier(.23,1,.32,1);      /* entering / responding */
  --ease-in-out: cubic-bezier(.77,0,.175,1);     /* moving on screen */
  --ease-drawer: cubic-bezier(.32,.72,0,1);      /* sheets and drawers */
  --t-press: 140ms;   /* press feedback must beat conscious perception */
  --t-exit:  140ms;   /* exits are faster than entries — see below */
}

/* Anything pressable confirms the press. Scale (not translate) because it
   carries the icon and label with it, so the whole control responds. */
.btn, .cw-btn-primary, .cw-cta, .pt-ap-view, .pt-notif-optin-enable,
button.theme-toggle, .pt-sb-item, .hp-tab {
  transition: transform var(--t-press) var(--ease-out),
              background var(--t-fast) var(--ease-out),
              color var(--t-fast) var(--ease-out),
              border-color var(--t-fast) var(--ease-out);
}
.btn:active, .cw-btn-primary:active, .cw-cta:active,
.pt-ap-view:active, .pt-notif-optin-enable:active, button.theme-toggle:active {
  transform: scale(.97);
}
/* The old rule nudged the button down 1px, which reads as the page
   shifting rather than the control responding. */
.btn:active { transform: scale(.97); box-shadow: inset 0 1px 2px rgba(0,0,0,.28); }

/* Hover belongs to devices that can hover. On a touch screen these fire
   on tap and stick, which looks like a stuck state. */
@media (hover: hover) and (pointer: fine) {
  .pt-bar-row:hover .pt-bar-fill { filter: brightness(1.12); }
  .pt-chart-table summary:hover { text-decoration: underline; }
}

/* Exits run faster than entries: the user has already decided, and
   waiting for a leaving element is pure delay. */
.pt-approval-popup.pt-leaving,
.pt-notif-optin.pt-leaving {
  opacity: 0; transform: translateX(16px);
  transition: opacity var(--t-exit) var(--ease-out), transform var(--t-exit) var(--ease-out);
}

/* Cards arrive staggered rather than all at once — but only a few, and
   only briefly: stagger is decorative and must never gate interaction. */
.pt-analytics .card { animation: cardRise 320ms var(--ease-out) both; }
.pt-analytics .card:nth-child(2) { animation-delay: 60ms; }
.pt-analytics .card:nth-child(3) { animation-delay: 120ms; }
.pt-analytics .card:nth-child(4) { animation-delay: 180ms; }

@media (prefers-reduced-motion: reduce) {
  /* Reduced motion means gentler, not none: opacity and colour still
     carry meaning, movement is what causes trouble. */
  .pt-bar-fill, .pt-ring-fill { transition: none; }
  .pt-analytics .card { animation: none; }
  .btn:active, .cw-btn-primary:active, .cw-cta:active { transform: none; }
}

/* ══ Alerts float ON TOP of the page ═════════════════════════════════
   There was a version of this that reserved a right-hand rail and pushed
   the page's content out of the way while alerts were showing. It was
   worse than the problem it solved: the rail could only be applied to
   blocks that were listed by name, so SOME dashboard cards narrowed and
   others did not, and the page visibly reflowed every time an alert
   arrived or was dismissed. On a phone the same idea docked the stack to
   the bottom and added 42vh of body padding, which left the page looking
   broken with a large empty gap under it.

   Overlaying is the right answer for four small cards that the person
   clears in a few seconds: nothing below them ever moves, and the cap of
   four plus a bounded, scrolling container is what stops them burying the
   page.

   ── Why this box takes the pointer ──
   An earlier version set pointer-events:none here so the empty space
   around the cards could not swallow a click meant for the page. That
   also stopped it receiving wheel and touch events, which broke both
   scrolling and — now — swiping between cards. The dead area is dealt
   with by height:fit-content instead: the box is exactly as tall as the
   front card plus its peeks, so there is barely any empty space to
   swallow anything. */
.pt-approval-stack {
  pointer-events: auto;
  height: fit-content;
  overscroll-behavior: contain;
}

@media (min-width: 1180px) {
  /* This rule sits ~1000 lines after the base one and was quietly winning
     with a FLAT 76px, so the site-bar offset added to the base rule never
     applied on any wide screen. With a bar present the stack stayed at 76
     while everything else moved down to clear it, and its count-and-arrows
     row landed straight on top of Install App and Instant Alerts.

     Same offset as the base rule. Media queries add no specificity, so
     whichever is later simply wins — which is exactly how this was missed. */
  .pt-approval-stack { top: calc(76px + var(--pt-sitebar-h, 0px)); right: 20px; width: 344px; }
}

/* ── Composition bar ─────────────────────────────────────────────
   Segments are separated by a 2px SURFACE gap rather than a border: a
   border darkens the boundary and starts reading as its own colour. */
.pt-stack-bar { display:flex; height:15px; border-radius:8px; overflow:hidden; background:var(--surface-2); gap:2px; }
.pt-seg {
  display:block; height:100%; min-width:2px;
  /* Grows from zero width on first paint. ease-out so it arrives quickly
     and settles, rather than creeping in. */
  animation: ptSegGrow 460ms var(--ease-out) both;
}
@keyframes ptSegGrow { from { transform: scaleX(0); transform-origin: left; } to { transform: scaleX(1); } }
.pt-seg-keys { display:flex; flex-wrap:wrap; gap:14px; margin-top:12px; }
.pt-seg-key { display:flex; align-items:center; gap:6px; font-size:11px; color:var(--ink-2); }
.pt-seg-key i { width:9px; height:9px; border-radius:2px; flex:none; }
.pt-seg-key b { color:var(--ink-1); font-family:var(--font-mono); }
.pt-seg-key small { color:var(--ink-3); font-family:var(--font-mono); font-size:10px; }

/* ── Ring row ────────────────────────────────────────────────────
   Staggered by 60ms — long enough to read as a cascade, short enough
   that the last ring is not still arriving when the eye reaches it. */
.pt-ring-row { display:flex; flex-wrap:wrap; gap:18px; justify-content:space-around; }
.pt-ring-cell { flex:1 1 118px; min-width:110px; animation: cardRise 300ms var(--ease-out) both; animation-delay: calc(var(--i) * 60ms); }
.pt-ring-cell .pt-ring { width:114px; height:114px; }
.pt-ring-cell .pt-ring-mid b { font-size:25px; }

@media (prefers-reduced-motion: reduce) {
  .pt-seg, .pt-ring-cell { animation: none; }
}

/* ── Copyright line ───────────────────────────────────────────────
   Injected at the end of <body> by api.js on every page that loads it;
   written into the markup directly on eula.html and setup.html, which
   do not. Quiet by design — it is a legal notice, not a navigation
   element, so it sits below everything and takes the dimmest ink.

   Normal flow, not fixed: it has to scroll away rather than sit on top
   of Home's bottom user bar or the phone alert stack. */
.pt-copyright {
  padding: 16px 20px 22px;
  text-align: center;
  font-size: 11.5px;
  color: var(--ink-3);
  letter-spacing: .01em;
}
/* NOTE: an adjacent-sibling rule against .cw-footer does not work here —
   the injected line is appended to <body>, and on the welcome page an
   unclassed element sits between the two. Each side owns its own spacing
   instead, which is why the paddings below are modest. */

@media print { .pt-copyright { display: none; } }

/* A row arrived at from a "work on this report" link. Brief, so it says
   "this is the one you clicked" without becoming permanent decoration. */
.fe-reg-row.so-deeplinked {
  animation: so-deeplink-pulse 2.4s ease-out 1;
}
@keyframes so-deeplink-pulse {
  0%, 55% { background: var(--accent-soft, rgba(222,152,98,.16)); box-shadow: inset 3px 0 0 var(--accent); }
  100%    { background: transparent; box-shadow: inset 3px 0 0 transparent; }
}
@media (prefers-reduced-motion: reduce) {
  .fe-reg-row.so-deeplinked { animation: none; box-shadow: inset 3px 0 0 var(--accent); }
}


/* ── Touch targets — small in-table actions ─────────────────────────
   See the note in the commit: the top-bar cluster is deliberately excluded
   (its size was reverted on purpose after it looked unbalanced). This is
   only about the text actions inside table rows, which are plain anchors
   and so were never caught by the .btn rule above.

   The padding grows the tap target; the negative margin gives the space
   back so nothing moves. The min-height on the cell keeps two rows'
   enlarged areas from overlapping — a tap landing on the row above is a
   worse failure than a small target. */
@media (pointer:coarse) {
  .list-table td a[href^="javascript"],
  .list-table td a[onclick],
  .list-table td button:not(.btn) {
    display:inline-block;
    padding-block:14px;
    margin-block:-14px;
    /* Without this the inline-block collapses to the text width and the
       horizontal target stays as narrow as the word. */
    /* Wide enough as well as tall enough. "Edit" and "Areas" reached
       44px high but stayed 34px wide, which is still a thin ribbon to
       aim at. 14px a side clears 44 for even the shortest label. */
    padding-inline:14px;
    margin-inline:-14px;
  }
  .list-table td { min-height:44px; }
  .list-table td, .list-table th { padding-block:11px; }

  /* Checkboxes are NOT padded here, and that is deliberate. A native
     checkbox ignores padding altogether in every engine — so the rule
     that was here applied the negative margin, got no growth in return,
     and left each box overlapping its neighbours by 11px for nothing.
     It does not need one either: every checkbox in this app sits inside
     its own <label>, and a label toggles the control it contains, so the
     real target is the label's full width — 200px+, not 24. Measuring
     the input on its own is what makes it look too small.

     The labels themselves are given a floor, though: they came out 30-41px
     tall, and a permission list is a column of them, so the few pixels
     decide whether the right one gets ticked. Width was never the problem
     — these run the full width of the card. */
  label:has(> input[type="checkbox"]),
  label:has(> input[type="radio"]) {
    min-height:44px;
    display:flex; align-items:center; gap:8px;
  }
}


/* ── Top-bar cluster — hit area only ────────────────────────────
   The visible size of these is deliberate and unchanged: enlarging them for
   real was tried, looked unbalanced, and was reverted. That judgement
   stands. This grows only the rectangle a finger has to land on.

   An absolutely-positioned ::after is used rather than padding with a
   negative margin. Padding was tried first and the arithmetic has to be
   exact — get it wrong by a few pixels and the whole bar changes height,
   which is precisely the complaint that got the earlier attempt reverted.
   (It did: the bar went from 118px to 85px and every item shifted left.)
   A pseudo-element cannot do that. It takes part in hit-testing and takes
   no part in layout, so the bar is bit-for-bit where it was.

   Coarse pointers only. A mouse does not need this. */
@media (pointer: coarse) {
  .nav-links a,
  .nav-links button,
  .user-bar a,
  .pt-tour-btn,
  .theme-toggle,
  .pt-bell,
  .pt-notif-bell {
    position: relative;
  }
  .nav-links a::after,
  .nav-links button::after,
  .user-bar a::after,
  .pt-tour-btn::after,
  .theme-toggle::after,
  .pt-bell::after,
  .pt-notif-bell::after {
    content: '';
    position: absolute;
    /* Reaches out far enough that even the shortest label ("Home", 48x24)
       and the smallest square control (26x26) clear 44px in both axes. */
    top: -10px; bottom: -10px; left: -9px; right: -9px;
    /* No paint of any kind. It exists to be hit. */
    background: transparent;
  }
  /* Adjacent targets now overlap by design — 9px of reach each into an
     ~8px gap. The one whose centre is nearer wins, which is the behaviour
     a finger expects; without the reach, the gap belongs to nobody. */
}
