/* ==========================================================================
   Wollam Construction — internal application design system
   v1.0 · single source of truth. Do not redefine these tokens in a page.
   One theme. One blue. One shape per role.
   ========================================================================== */

/* --- 1. Tokens ----------------------------------------------------------- */
:root {
  /* Brand — navy is the ONLY accent and the ONLY blue in the system.
     Gold is a logo-lockup color. Never use it in UI. */
  --wollam-navy:        #00347E;
  --wollam-navy-hover:  #002A66;
  --wollam-navy-tint:   rgba(0, 52, 126, .08);
  --wollam-gold:        #C8A03C;   /* logo lockup only — forbidden in UI */

  /* Ink */
  --text-primary:    #0F172A;
  --text-secondary:  #475569;
  /* 2.56:1 on white — under the 4.5:1 AA text threshold. Owner's explicit
     ruling: this token is for DECORATIVE use only — input placeholders,
     dividers/separators, disabled-state text, purely ornamental glyphs.
     Anything a user reads to know something (dates, counts, summaries,
     breadcrumbs, filter/section labels, footer links, eyebrows, meta
     lines) is INFORMATIONAL and belongs on --text-secondary (7.58:1)
     instead. Do not reintroduce an informational use here, and do not
     darken this token to "fix" contrast — that collapses tertiary and
     secondary into near-identical greys. See the changelog entry below. */
  --text-tertiary:   #94A3B8;
  --text-disabled:   #CBD5E1;
  --text-on-navy:    #FFFFFF;

  /* Surface */
  --bg-canvas:   #F8FAFC;   /* app background — the only page grey */
  --bg-surface:  #FFFFFF;   /* cards, tables, drawers, bars */
  --bg-sunken:   #F1F5F9;   /* read-only blocks, chart wells */
  --bg-selected: var(--wollam-navy-tint);
  /* Legacy alias — still referenced by chat.css, which hasn't migrated to
     the current token names. Retire once chat.css uses --bg-sunken directly. */
  --bg-hover:    var(--bg-sunken);

  /* Border — exactly three weights of grey, by role */
  --border-strong: #CBD5E1;  /* frame edges, table header rule */
  --border:        #E2E8F0;  /* default 1px container + control border */
  --border-subtle: #F1F5F9;  /* dividers inside a single card */
  --bw:            1px;      /* desktop border width */
  --bw-card:       1px;      /* becomes 1.5px on mobile — see §9 */
  /* Legacy alias — still referenced by chat.css, which hasn't migrated to
     the current token names. Retire once chat.css uses --border directly. */
  --border-default: var(--border);

  /* Status — the only colors besides navy and grey.
     Each has: solid (dots, borders, numbers), text (on tint), tint (fills). */
  --status-danger:        #DC2626;  /* Recordable, First Aid, overdue */
  --status-danger-text:   #B91C1C;
  --status-danger-tint:   rgba(220, 38, 38, .15);
  --status-warning:       #D97706;  /* Near Miss, unreviewed, due soon */
  --status-warning-text:  #B45309;
  --status-warning-tint:  rgba(217, 119, 6, .15);
  --status-success:       #16A34A;  /* closed, completed, recognition */
  --status-success-text:  #15803D;
  --status-success-tint:  rgba(22, 163, 74, .15);
  --status-critical:      #7C3AED;  /* LTI only */
  --status-critical-text: #6D28D9;
  --status-critical-tint: rgba(124, 58, 237, .15);
  --status-neutral:       #94A3B8;  /* everything unclassified */
  --status-neutral-text:  #475569;
  --status-neutral-tint:  #F1F5F9;
  /* Informational is navy. There is no second blue. */
  --status-info:          var(--wollam-navy);
  --status-info-tint:     var(--wollam-navy-tint);

  /* Type — Inter, 8 steps. No size outside this list ships. */
  --font: 'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif;
  --font-mono: ui-monospace, SFMono-Regular, Menlo, monospace;
  --t-nano:     10px;  /* tab-bar labels, sidebar group headings — chrome only, never content */
  --t-micro:    11px;  /* eyebrows, table headers, OSHA flag — 700/upper/.06em */
  --t-caption:  12px;  /* badge text, timestamps, meta */
  --t-compact:  13px;  /* dense table cells, desktop controls */
  --t-body:     14px;  /* desktop body */
  --t-lead:     16px;  /* mobile body + EVERY mobile input (iOS zoom floor) */
  --t-subhead:  20px;  /* in-page section titles, KPI numbers */
  --t-title:    26px;  /* page h1 — 700 / -.02em */
  --t-metric:   44px;  /* the one hero number on a screen — tabular */

  /* Space — 4px base */
  --space-1: 4px;  --space-2: 8px;  --space-3: 12px; --space-4: 16px;
  --space-5: 20px; --space-6: 24px; --space-7: 32px; --space-8: 48px;

  /* Radius — by role, not by taste */
  --r-control:   8px;      /* anything clickable: button, select, input, pill-filter */
  --r-container: 12px;     /* card, table frame, drawer, KPI tile */
  --r-check:     4px;      /* checkbox */
  --r-pill:      9999px;   /* status badge ONLY — never on a control */
  --r-indicator: 3px;      /* the rounded end of an active-nav bar. Indicator
                              geometry, NOT container geometry — it is composed
                              into a compound radius (0 3px 3px 0) so the bar
                              meets its edge square and rounds only the free
                              end. Never use it on a box. */

  /* Shadow — three, by elevation */
  --shadow-card:    0 1px 3px rgba(0, 0, 0, .04);
  --shadow-raised:  0 4px 6px rgba(0, 0, 0, .04), 0 2px 4px rgba(0, 0, 0, .06);
  --shadow-drawer: -4px 0 24px rgba(0, 0, 0, .15);

  /* Chrome geometry */
  --sidebar-w:           240px;
  --sidebar-collapsed-w: 64px;
  --header-h:            56px;
  --tabbar-h:            64px;
  /* 520px, not 560 — the cross-page consistency audit's canonical drawer
     width (fix 6). Every one of the three .panel consumers overrode this
     token's old 560px value: observations.html/equipment_inspections.html
     to 520px (byte-identical local overrides — a governance rule 3 miss),
     incidents.html's #ca-drawer to 420px (a narrower, simpler drawer —
     left as its own override; see that page's page_css comment). Since no
     page actually used 560px, moving the default to 520px let both
     identical overrides be deleted instead of graduating a third, new
     modifier class. */
  --drawer-w:            520px;
  --control-h:           32px;   /* desktop filter/select/search */
  --control-h-touch:     44px;   /* mobile minimum — never smaller */
  --btn-h:               36px;
  --btn-h-touch:         48px;
  --focus-ring:          0 0 0 3px rgba(0, 52, 126, .28);
  --t-fast: 120ms ease;
}

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

/* The [hidden] attribute must actually hide. The UA stylesheet's
   [hidden]{display:none} loses to ANY author rule that sets display — so
   .btn's display:inline-flex left `<button class="btn" hidden>` fully
   visible (found on banner_editor's Unpublish control). The same trap
   applies to every component here that sets display: .card, .panel,
   .filter-strip, .modal, .tabbar. Fixed once, at the origin. */
[hidden] { display: none !important; }

body {
  font-family: var(--font);
  font-size: var(--t-body);
  line-height: 1.5;
  color: var(--text-primary);
  background: var(--bg-canvas);
  -webkit-font-smoothing: antialiased;
}

a { color: var(--wollam-navy); text-decoration: none; }
a:hover { color: var(--wollam-navy-hover); text-decoration: underline; }

:focus-visible { outline: none; box-shadow: var(--focus-ring); border-radius: var(--r-control); }

h1 { font-size: var(--t-title);   font-weight: 700; letter-spacing: -.02em; }
h2 { font-size: var(--t-subhead); font-weight: 700; }
h3 { font-size: var(--t-lead);    font-weight: 600; }

.num { font-variant-numeric: tabular-nums; }  /* every column of digits */

.eyebrow {
  font-size: var(--t-micro); font-weight: 700; text-transform: uppercase;
  letter-spacing: .08em; color: var(--text-secondary);
}

.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;
}

/* --- 3. App shell ------------------------------------------------------- */
/* z-index scale — low to high. Add new layers here so the next person has
   a single reference instead of guessing a number that happens to "work".
     .chat-fab (static/css/chat.css)  44   — below the tab bar; the chat
                                              button is a page-level FAB,
                                              not a navigation layer
     .tabbar                          45
     .sidebar-overlay                 49
     .sidebar (mobile off-canvas)     50
     .mobile-menu-btn                 51   — above the sidebar (close icon
                                              stays tappable while open),
                                              below the drawer and modal
                                              (never floats over them)
     .panel-backdrop (drawer)         60
     .panel (drawer)                  61
     .chat-backdrop (chat.css)        62
     .chat-panel (chat.css)           63
     .modal-backdrop                  70
     .toast                           80

   .chat-backdrop/.chat-panel are slotted at 62/63 here — above the
   drawer, below the modal/toast. static/css/mobile.css used to carry a
   transitional 82/83 override for these two classes, clearing its own
   mobile chrome (hamburger, sidebar overlay, open sidebar — stacked up to
   z-index 80 at <=900px) while it was still loaded by the templates not
   yet converted to this design system. All 24 templates now extend the
   base shell and mobile.css has been deleted (Phase 4) — the override
   went with it, and 62/63 is the real, permanent value everywhere. See
   the changelog entry below for the fuller history.
*/
.app { display: flex; min-height: 100vh; }

/* Fixed full height: the nav is how you leave a page, so it must not
   scroll away three screens into a 484-row roster. Below 900px the mobile
   block re-declares this as an off-canvas drawer (same position:fixed, plus
   a translate), so the two do not fight. overflow-y keeps a long nav
   reachable on a short laptop screen. */
.sidebar {
  position: fixed; top: 0; left: 0; bottom: 0; z-index: 40;
  width: var(--sidebar-w); flex-shrink: 0; background: var(--wollam-navy);
  padding: var(--space-5) var(--space-3); display: flex; flex-direction: column;
  overflow-y: auto;
}
.sidebar-logo {
  padding: 0 var(--space-3) var(--space-5);
  border-bottom: var(--bw) solid rgba(255, 255, 255, .10);
  margin-bottom: var(--space-4);
}
.sidebar-logo img { height: 32px; filter: brightness(0) invert(1); }
.sidebar-group {
  font-size: var(--t-nano); font-weight: 700; text-transform: uppercase; letter-spacing: .06em;
  color: rgba(255, 255, 255, .35); padding: var(--space-2) var(--space-3) var(--space-1);
}
.sidebar-nav { display: flex; flex-direction: column; gap: 2px; }
.sidebar-nav a {
  position: relative; display: flex; align-items: center; gap: 10px;
  padding: var(--space-2) var(--space-3);
  border-radius: var(--r-control); font-size: var(--t-compact); font-weight: 500;
  color: rgba(255, 255, 255, .7); text-decoration: none; transition: background var(--t-fast);
}
.sidebar-nav a:hover { background: rgba(255, 255, 255, .08); color: #fff; text-decoration: none; }
.sidebar-nav a.active { background: rgba(255, 255, 255, .15); color: #fff; font-weight: 600; }
/* Active bar is WHITE against navy. Gold is not a UI color. */
.sidebar-nav a.active::before {
  content: ''; position: absolute; left: 0; top: 6px; bottom: 6px; width: 3px;
  border-radius: 0 var(--r-indicator) var(--r-indicator) 0; background: #fff;
}

/* App shell additions — icons, spacer, footer, mobile trigger. */
.sidebar-nav a svg { width: 18px; height: 18px; flex-shrink: 0; opacity: .7; }
.sidebar-nav a.active svg { opacity: 1; }
.sidebar-spacer { flex: 1; }
.sidebar-footer {
  display: flex; align-items: center; gap: var(--space-2);
  padding: var(--space-2) var(--space-3);
  color: rgba(255, 255, 255, .5); font-size: var(--t-caption);
}
.mobile-menu-btn { display: none; }
.sidebar-overlay { display: none; }

/* The sidebar is out of flow now, so .main reserves its width here. */
.main { flex: 1; display: flex; flex-direction: column; min-width: 0;
        margin-left: var(--sidebar-w); }

/* Sticky for the same reason the sidebar is fixed: Logout should not
   require scrolling back to the top of a long list. z-index sits below the
   sidebar (40) so the mobile drawer still covers it. */
.header {
  position: sticky; top: 0; z-index: 30;
  height: var(--header-h); flex-shrink: 0; background: var(--bg-surface);
  border-bottom: var(--bw) solid var(--border);
  display: flex; align-items: center; justify-content: space-between;
  padding: 0 var(--space-6); gap: var(--space-3);
}
.header-title { font-size: var(--t-lead); font-weight: 600; }
.logo-mark { height: 24px; flex-shrink: 0; }

.content { flex: 1; padding: var(--space-6); }
/* Settings and form pages read better capped; data-table pages stay full
   width. Shell decision (block content_class in _base.html), not a
   page-local override. */
.content.is-narrow { max-width: 960px; }

.page-head {
  display: flex; justify-content: space-between; align-items: flex-start;
  gap: var(--space-4); margin-bottom: var(--space-5);
}
.page-head p { font-size: var(--t-compact); color: var(--text-secondary); margin-top: 2px; }
/* The action cluster on the right of a page head. Graduated out of
   observations.html when equipment_inspections.html grew a "+ New
   Inspection" button and became the second consumer (governance rule 3) —
   the class-resolution checker caught the reference before it shipped
   unstyled. `flex-wrap` on .page-head belongs with it: a title and an
   action cluster must be able to wrap onto two lines on a narrow viewport
   rather than squeezing the title. */
.page-head { flex-wrap: wrap; align-items: center; }
.header-actions { display: flex; gap: var(--space-2); align-items: center; }

/* Breadcrumb — graduated out of banner_view.html and incident_detail.html
   (see the changelog entry below). Link colour/hover comes from the global
   `a`/`a:hover` rule above, deliberately not overridden here so it can't
   drift out of sync. */
.breadcrumb { font-size: var(--t-compact); color: var(--text-secondary); margin-bottom: var(--space-2); }

/* Detail-page header action row — graduated out of incident_detail.html and
   banner_editor.html, the second converted consumer (governance rule 3).
   Both pre-conversion definitions matched except banner_editor.html's extra
   align-items: flex-start (needed once a relatively-positioned overflow
   menu sits in the row); harmless on incident_detail.html's plain button
   row (align-items: stretch and flex-start read identically when every
   child is the same 44px height), so it is included unconditionally. */
.detail-header-actions { display: flex; align-items: flex-start; gap: var(--space-2); }
@media (max-width: 900px) {
  .detail-header-actions { flex-wrap: wrap; }
}

/* A running count next to a .page-head h1 ("Equipment Roster 483 units").
   Graduated out of equipment.html and equipment_inspections.html, which
   defined it byte-identical (governance rule 3 miss, cross-page
   consistency audit finding 6). */
.header-count { font-size: var(--t-compact); font-weight: 400; color: var(--text-secondary); margin-left: var(--space-2); }

/* --- 4. Buttons — four variants, one shape ------------------------------ */
.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: var(--space-2);
  height: var(--btn-h); padding: 0 var(--space-4);
  border-radius: var(--r-control); border: var(--bw) solid transparent;
  font-family: inherit; font-size: var(--t-body); font-weight: 600;
  cursor: pointer; white-space: nowrap; transition: background var(--t-fast), border-color var(--t-fast);
}
/* An <a class="btn"> is a button, not a link. Without this the global
   a:hover (0,1,1) beats .btn (0,1,0) and underlines every anchor-button —
   the header Logout, and incident_detail's Save to PDF / Edit / Create Banner. */
.btn, .btn:hover { text-decoration: none; }
.btn-primary   { background: var(--wollam-navy); color: #fff; }
.btn-primary:hover { background: var(--wollam-navy-hover); }
.btn-secondary { background: var(--bg-surface); color: var(--text-primary); border-color: var(--border); }
.btn-secondary:hover { border-color: var(--border-strong); background: var(--bg-canvas); }
.btn-danger    { background: var(--status-danger); color: #fff; }
.btn-danger:hover { background: var(--status-danger-text); }
/* Destructive, but not the only thing on screen.
   .btn-danger is solid red — right for the one confirm button in a dialog,
   far too loud repeated down every row of a list. `.is-danger` is the row-
   level variant: it reads as an ordinary secondary button until you reach
   for it, then commits to red. Before this, tag settings' "Remove" was
   byte-identical to its "Rename" beside it, and communications' "Delete"
   was a bare .btn — nothing distinguished the action that cannot be undone
   from the one that can. */
.btn.is-danger { color: var(--status-danger-text); }
.btn.is-danger:hover {
  background: var(--status-danger-tint); border-color: var(--status-danger);
  color: var(--status-danger-text);
}
/* Icon-only destructive row action. Graduated out of users.html 2026-08-14. */
.btn-icon.is-danger:hover { background: var(--status-danger-tint); color: var(--status-danger-text); }
.btn-ghost     { background: transparent; color: var(--text-secondary); font-weight: 500; }
.btn-ghost:hover { background: var(--bg-sunken); color: var(--text-primary); }
.btn-sm { height: var(--control-h); padding: 0 var(--space-3); font-size: var(--t-compact); }
.btn:disabled { opacity: .5; cursor: not-allowed; }
/* One primary button per view. Destructive actions confirm — see .modal-sm. */

/* --- 5. Badges — status only, pill shape, never clickable --------------- */
.badge {
  display: inline-flex; align-items: center; padding: 2px 10px;
  border-radius: var(--r-pill); font-size: var(--t-caption); font-weight: 600;
  white-space: nowrap;
}
/* ── The status taxonomy ──────────────────────────────────────────────
   Three kinds of information were sharing one visual language. A reader
   could not tell, from the look of a pill, whether it described where the
   record IS, something ABOUT it, or where it CAME FROM. Worse, observations
   rendered its source tag in --status-critical purple, which §5 reserves
   for LTI — the single most serious severity in the system — to say "this
   arrived via the web form".

   Three components, three visual weights, one rule each:

     .badge      WORKFLOW STATE. Where the record is in its lifecycle, and
                 nothing else. Filled tint pill. ONE per row (§1 rule 3).
     .chip       METADATA. A count, a category, an attribute. Neutral,
                 never colour-coded — colour here competes with the badge
                 for the reader's "is this a problem?" glance.
     .tag-source PROVENANCE. Where it came from. Quietest of the three:
                 micro caps, no fill, because it is the least load-bearing
                 fact on the row and was the loudest.

   Severity stays out of all three — it is a 4px left border and nowhere
   else (see .sev-* below). */
.badge-open         { background: var(--status-info-tint);     color: var(--wollam-navy); }
.badge-investigating{ background: var(--status-warning-tint);  color: var(--status-warning-text); }
.badge-closed       { background: var(--status-success-tint);  color: var(--status-success-text); }
.badge-neutral      { background: var(--status-neutral-tint);  color: var(--status-neutral-text); }
/* Observation review states. Same three-token pattern as the incident
   states above, so the two lists cannot drift apart in tone. */
.badge-unreviewed   { background: var(--status-warning-tint);  color: var(--status-warning-text); }
.badge-flagged      { background: var(--status-danger-tint);   color: var(--status-danger-text); }
.badge-reviewed     { background: var(--status-success-tint);  color: var(--status-success-text); }
/* "Active" on a roster row is a workflow state like any other, not plain
   body text with no affordance. */
.badge-active       { background: var(--status-success-tint);  color: var(--status-success-text); }

/* Generic outcome states, for anything whose vocabulary is plain
   success/warning/failure rather than a named workflow (the PDF ingestion
   log). Graduated out of upload.html 2026-08-14 — same three token pairs
   every other state variant above uses. */
.badge-success      { background: var(--status-success-tint);  color: var(--status-success-text); }
.badge-warning      { background: var(--status-warning-tint);  color: var(--status-warning-text); }
.badge-danger-state { background: var(--status-danger-tint);   color: var(--status-danger-text); }

/* Metadata. Deliberately has no colour variants: the moment one exists,
   someone reaches for red and the row has two things claiming urgency. */
.chip {
  display: inline-flex; align-items: center; gap: var(--space-1);
  /* Pill radius, matching .badge. It was --r-control (8px), which §1 rule 4
     reserves for CLICKABLE things — a metadata chip is not clickable, and
     the mismatched shape made observation tags and incident severity pills
     read as unrelated design languages sitting two nav items apart.
     One family now: same shape, and the weight is what separates them —
     a badge is tinted and states where the record IS, a chip is neutral and
     is just a fact about it. */
  padding: 2px var(--space-3); border-radius: var(--r-pill);
  background: var(--bg-sunken); color: var(--text-secondary);
  font-size: var(--t-caption); font-weight: 500; white-space: nowrap;
}

/* Provenance. No fill at all — it sits behind everything else on the row. */
.tag-source {
  font-size: var(--t-micro); font-weight: 700; letter-spacing: .06em;
  text-transform: uppercase; color: var(--text-tertiary); white-space: nowrap;
}

/* Severity, as a table cell. §5's "4px left border and nowhere else" is the
   CARD treatment — a card has an edge to carry it; a table row does not, and
   Severity is a real column on /incidents. These five share the exact tokens
   the .sev-* borders use, so the card and the table can never disagree about
   what an LTI looks like. Graduated out of incidents.html 2026-08-14.
   Nothing outside a severity context may use --status-critical. */
.badge-severity-fa  { background: var(--status-success-tint);  color: var(--status-success-text); }
.badge-severity-rec { background: var(--status-danger-tint);   color: var(--status-danger-text); }
.badge-severity-lti { background: var(--status-critical-tint); color: var(--status-critical-text); }
.badge-severity-nm  { background: var(--status-warning-tint);  color: var(--status-warning-text); }
.badge-severity-na  { background: var(--status-neutral-tint);  color: var(--status-neutral-text); }

/* A second pill in a row is forbidden. Secondary flags are micro caps: */
.flag {
  font-size: var(--t-micro); font-weight: 700; letter-spacing: .06em;
  text-transform: uppercase; color: var(--status-danger);
}

/* Severity is a 4px left border on a card, and nowhere else. */
.sev-lti        { border-left: 4px solid var(--status-critical); }
.sev-recordable { border-left: 4px solid var(--status-danger); }
.sev-nearmiss   { border-left: 4px solid var(--status-warning); }
.sev-other      { border-left: 4px solid var(--status-neutral); }

/* --- 6. Filter strip — one row: pills, dropdowns, search at auto -------- */
.filter-strip {
  display: flex; align-items: center; gap: var(--space-2);
  margin-bottom: var(--space-4); flex-wrap: wrap;
}
.filter-pill, .filter-select, .search-box {
  height: var(--control-h); padding: 0 var(--space-3);
  border-radius: var(--r-control); border: var(--bw) solid var(--border);
  background: var(--bg-surface); color: var(--text-secondary);
  font-family: inherit; font-size: var(--t-compact); font-weight: 500;
  display: inline-flex; align-items: center; gap: 6px; cursor: pointer;
}
.filter-pill.active {
  background: var(--wollam-navy); border-color: var(--wollam-navy); color: #fff;
}
/* The tab count. ONE treatment across every list page.
   observations.html overrode this into an 18px filled circle, which a
   three-digit count ("407") squashed into something that reads as a toggle
   switch rather than a number; equipment_inspections.html had grown a third
   variant (.tab-count) beside it. A plain tabular number survives any digit
   count and never competes with the status badges in the rows below. */
.filter-pill .count { font-weight: 700; font-variant-numeric: tabular-nums; margin-left: 4px; }
/* Amber only when the tab is holding work: an unreviewed queue that is not
   empty is the one count worth a colour. Never on the active tab, where
   amber-on-navy fails contrast. */
.filter-pill.has-items .count { color: var(--status-warning); }
.filter-pill.active.has-items .count { color: var(--text-on-navy); }
.filter-select.has-value { border-color: var(--wollam-navy); color: var(--text-primary); }
/* 300px, not 220 — the cross-page consistency audit's canonical width
   (fix 4). Three pages searched a plain-text box at margin-left:auto and
   disagreed on width: observations.html/equipment.html used the (then)
   default 220px, equipment_inspections.html overrode to 300px because its
   placeholder ("Search unit, inspector, operator, project, notes") needs
   the room. 300px reads fine on the two shorter-placeholder pages too — a
   .filter-strip already wraps at the 900px floor — so it became the one
   shared value instead of a per-page override; equipment_inspections.
   html's local override was deleted as redundant. */
.search-box { margin-left: auto; width: 300px; cursor: text; }
.filter-divider { width: 1px; height: 22px; background: var(--border); margin: 0 var(--space-1); }
/* Explicit ordering element for a .filter-strip: "pills -> dropdowns ->
   search at margin-left:auto" (this section's own heading) was enforced
   only by margin-left:auto on .search-box itself — a strip with no search
   box (or one that needs to push a button, not a search box, to the right)
   had no shared way to say "everything after this point goes right" and
   had to invent one: observations.html's own `.spacer`, dashboard.html's
   `.filter-reset { margin-left: auto }` baked onto the Clear-filters
   button itself. Both graduated onto this one flex-spacer element instead
   (cross-page consistency audit, fix 4) — see each page's page_css/markup
   for the swap. */
.filter-spacer { flex: 1; }

/* Label + control pairing inside a .filter-strip — a smaller grouping than
   the strip itself, so a label and its control wrap to the next line as one
   unit at the 900px breakpoint instead of splitting apart. Graduated out of
   incidents.html and dashboard.html, the second converted consumer
   (governance rule 3) — both defined it identically. max-width + min-width:0
   are load-bearing, not decoration: a <select> sizes to its longest option
   (project names run long), and a flex item will not shrink below its
   content unless min-width:0 lets it. Without these the field renders wider
   than a phone viewport and the PAGE scrolls sideways — the filter strip
   wrapping does not save you, because it is one item that is too wide, not
   too many items. */
.filter-field {
  display: flex; align-items: center; gap: var(--space-1);
  max-width: 100%; min-width: 0;
}
.filter-field .filter-select { min-width: 0; }
/* The same guard for a select sitting DIRECTLY in a strip, without a label
   to pair with. .filter-field exists for the label+control case and carries
   this on its wrapper; a bare select had nothing, and the strip wrapping
   does not save you — it is one item too wide, not too many items.
   Found by measuring: equipment_inspections' new Project filter lists
   project NAMES ("NG WATERLINE 34-6711_34-6712"), resolved to 368px inside
   a 375px viewport, and scrolled the whole page sideways. Observations
   escaped only because its equivalent filter lists project numbers. */
.filter-strip > .filter-select { max-width: 100%; min-width: 0; }
.filter-field-label { font-size: var(--t-caption); font-weight: 500; color: var(--text-secondary); flex-shrink: 0; }
/* The strip's own leading label ("Filter:", "Sort:") — micro caps, quieter
   than a .filter-field-label because it names the whole row rather than one
   control. Graduated out of observations.html when equipment_inspections
   .html grew a filter strip of its own (governance rule 3); the
   class-resolution checker caught the unstyled reference before it
   shipped. */
.filter-label {
  font-size: var(--t-micro); font-weight: 600; text-transform: uppercase;
  letter-spacing: .04em; color: var(--text-secondary);
}

/* --- 7. Table ----------------------------------------------------------- */
/* overflow-x: auto (not overflow: hidden) — a table wider than its frame
   must scroll horizontally within the frame, never clip unreachable columns
   /controls or force the page body to scroll sideways. auto still clips to
   border-radius on both axes, so the rounded-corner intent is unchanged.
   overflow-y stays hidden here (no vertical scroll at the base size); pages
   that also need a sticky header (projects.html, users.html) override
   overflow-y to auto locally alongside a max-height — see their page_css
   comments — which layers on top of this rule's overflow-x without
   resetting it, since page_css loads after this file. */
.table-frame {
  background: var(--bg-surface); border: var(--bw) solid var(--border);
  border-radius: var(--r-container); box-shadow: var(--shadow-card);
  /* overflow MUST stay `visible`, or the sticky header row below stops
     working. Any non-visible overflow value makes this element the sticky
     containing block, and the header then scrolls away with the frame
     instead of docking under the app bar.

     It used to be `overflow-x: auto` as a horizontal-scroll fallback. That
     fallback is not needed any more: §4 forbids horizontal scroll outright,
     below 900px these tables are replaced by cards, and every column is now
     content-sized against one designated flexible column (§7's
     .col-flex/.col-fit), so a table sizes itself to its frame rather than
     demanding more room than it has. */
  overflow: visible;
}
/* Below 1200px a six- or seven-column table can no longer fit whatever
   content it holds, however hard the columns yield. The frame takes the
   horizontal scroll back at that point rather than letting the PAGE scroll
   sideways, which is the worse of the two (§4: never a sideways page).

   This costs the sticky header below 1200px -- a non-visible overflow makes
   the frame the sticky containing block -- and that is the right way round:
   above 1200px, where these tables actually fit, headers pin and nothing
   scrolls sideways; below it, the table stays inside its own box.

   Above 900px only: below that these tables are replaced by cards. */
@media (max-width: 1400px) and (min-width: 901px) {
  /* Sized to the widest table's actual column set, not chosen. It was
     1600px while /equipment-inspections still carried Last operator (1 of
     51 rows populated) and a labelled Flag column (0 of 51); dropping the
     first and reducing the second to an icon returned 230px, and the table
     now clears its frame from 1440px up. The comment on the old bound said
     this number should come down rather than stay -- it did.
     Below this width the frame takes the horizontal scroll so the PAGE
     never scrolls sideways (§4). */
  .table-frame { overflow-x: auto; }
     The matching sticky-header offset for this width CANNOT live in this
     block: `.table-frame.has-sticky-header thead th` is defined below, and
     a media query adds no specificity, so a rule here is reset by it. It
     sits directly after that definition instead. */
}

.data-table { width: 100%; border-collapse: collapse; font-size: var(--t-compact); }
.data-table th {
  text-align: left; padding: 10px var(--space-4);
  font-size: var(--t-caption); font-weight: 600; color: var(--text-secondary);
  text-transform: uppercase; letter-spacing: .04em;
  border-bottom: var(--bw) solid var(--border-strong);
}
.data-table td {
  padding: 10px var(--space-4); border-bottom: var(--bw) solid var(--border);
  font-variant-numeric: tabular-nums;
}
/* The date cell, everywhere. Graduated out of observations.html on
   2026-08-12 when equipment_inspections.html became the second consumer
   (governance rule 3).

   Every table date is ONE line in ONE format — "Aug 12, 2026", always the
   year, abbreviated month, never uppercase. Inspections used to stack an
   uppercase year-less "JUL 30" over a separate "9:10 PM", which was both a
   second date format and the reason its rows were double the height of
   observation rows. Time belongs in the detail drawer, not the list. */
.data-table .td-date {
  white-space: nowrap; color: var(--text-secondary); font-size: var(--t-caption);
}
/* Column-alignment modifiers, for a th/td that isn't left-aligned text (a
   number column, a centered icon/toggle column). Graduated out of
   dashboard.html's `.data-table .align-right`/`.align-center` and
   incidents.html's `.cost-col { text-align: right }` — two names for the
   same job (cross-page consistency audit, fix 6). `.cost-col` is gone;
   incidents.html now uses `.align-right` like every other right-aligned
   numeric column. */
.data-table .align-right { text-align: right; }
.data-table .align-center { text-align: center; }
/* Clickable rows are opt-in, not the base component. .data-table used to
   bake `cursor: pointer` + a row hover into every consumer unconditionally
   — the three read-only tables that don't navigate on row click (upload.
   html's ingestion history, incident_detail.html's corrective actions,
   dashboard.html's recorders leaderboard) each had to either fork the whole
   component (.history-table, .ca-table) or paper over it with an inline
   `style="cursor:default"` (dashboard.html) just to stop looking clickable
   when it wasn't. Add `.is-clickable` to a `<table class="data-table">`
   only where a row click genuinely navigates or opens something — audited
   against each page's own JS, not assumed: observations.html (`<tr
   onclick="openPanel(...)">`), incidents.html's incidents-table
   (`onclick="window.location=..."`) and actions-table (`tr.addEventListener
   ('click', openCaDrawer)`), and equipment_inspections.html (`tr.
   addEventListener('click', openPanelFromRow)`). projects.html, users.html
   and equipment.html's rows only carry inner action buttons (edit/toggle/
   delete) — the row itself does nothing on click — so they stay plain
   `.data-table` and correctly lose the pointer cursor they used to render
   despite never having been clickable. */
.data-table.is-clickable tbody tr { cursor: pointer; }
.data-table.is-clickable tbody tr:hover { background: var(--bg-canvas); }
.data-table tbody tr.selected { background: var(--bg-selected); }
.data-table tbody tr.selected td:first-child { position: relative; }
.data-table tbody tr.selected td:first-child::before {
  content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 3px;
  background: var(--wollam-navy);
}

/* Sortable column header affordance — pointer cursor, hover tint, active-
   sort colour, and the ▲▼ arrow glyph's size/spacing. Graduated out of
   observations.html and equipment_inspections.html, the second converted
   consumer — governance rule 3; both defined it identically. Independent of
   `.is-clickable` above: a header's own onclick drives sorting regardless
   of whether its rows also navigate. */
/* ── Column sizing ──
   Every table designates ONE flexible column -- usually its primary text --
   and content-sizes the rest. Without a rule, whichever column happens to
   have no width constraint absorbs the slack: on /observations that was
   Tags, which measured 1036px of mostly white space while Description was
   capped at 360px and truncating beside it.

   In table layout `width: 100%` on one column makes it take the remainder,
   and `width: 1%` + nowrap collapses a column to its content. `max-width: 0`
   is what lets text-overflow engage inside a table cell at all.

   Graduated out of observations.html 2026-08-17 when equipment_inspections
   became the second consumer. */
.data-table .col-flex, .data-table .td-flex {
  width: 100%; max-width: 0;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* Content-sized: as narrow as the content allows ON ONE LINE.
   `nowrap` is INTRINSIC to what a fit column is, not an opt-in companion.
   Under auto table layout a shrink-to-content column that is allowed to
   wrap resolves to the width of its longest WORD, not its longest value —
   so it collapses and the content wraps to fill the space it was just
   denied. That is precisely what happened when nowrap was briefly made
   optional: observation reporter names broke across two lines and
   "PC 210 - Komatsu (Excavator)" rendered across three, taking row heights
   from a flat 41px to a ragged 40-75px inside one table.
   The overflow that removal was trying to solve is handled properly
   instead — by giving the slack to the right column (see .col-flex) and by
   the <=1200px frame scroll below. */
.data-table .col-fit, .data-table .td-fit {
  width: 1%; white-space: nowrap;
  /* The ceiling, as a PROPERTY rather than a constant. A fit column that
     can never wrap must never grow unbounded either, or it starves the rest
     of the table — but one number cannot serve a date, an equipment
     identifier carrying a status badge, a project name and a person's name.
     220px was silently truncating real data in three columns across two
     pages, and the per-page override written to rescue one of them is
     exactly how the badge families drifted apart.
     A column opts into a named size below; 220px remains the default for
     the ordinary case. */
  max-width: var(--col-max, 220px);
  overflow: hidden; text-overflow: ellipsis;
}
/* Named ceilings. Sized against real content, not taste — see the audit
   measurements in the commit message. Apply to BOTH the th and its tds:
   a custom property set on a th does not cascade down its column. */
.data-table .w-date  { --col-max: 140px; }   /* "Aug 12, 2026" */
.data-table .w-label { --col-max: 180px; }   /* a status, a short enum */
.data-table .w-name  { --col-max: 300px; }   /* a person, a project */
.data-table .w-id    { --col-max: 340px; }   /* identifier + a badge beside it */
.data-table .w-wide  { --col-max: 440px; }   /* the longest non-narrative field */

/* The trailing slack column.
   .col-flex absorbs leftover width unconditionally, which is right when a
   table HAS a long free-text column and wrong when it does not: a table
   whose longest value is ~230px and which designates a flex column anyway
   parks ~355px of void mid-table while the columns either side of it clip.
   A table with no narrative column names no flex column and adds this
   instead, so the slack collects at the trailing edge where it reads as
   margin rather than as a gap.

   NO CONSUMER as of 2026-08-19. /equipment-inspections was the measurement
   above and the only page that ever used it; the Findings column added that
   day gave that table the narrative field it had been missing, so it names
   `description` as its flex column like every other table and the trailing
   spacer went with the change. Kept rather than deleted, for the same
   reason `.panel-header` is: it is the documented other half of a pair — a
   table still has to answer "where does the leftover width go?", and
   without this the answer for a table with no narrative field is "wherever
   the browser decides". Deleting it would delete the answer along with the
   rule. */
/* Icon-width column, for a control rather than a field. A labelled column
   of em-dashes is the most expensive possible way to render an action
   nobody has taken yet; at icon width it stays visible and discoverable and
   reads as an affordance. Same slot and width as a row chevron. */
.data-table .col-icon, .data-table .td-icon {
  width: 48px; min-width: 48px; text-align: center; white-space: nowrap;
}
/* .flag-dot deleted 2026-08-19. It rendered "flagged" as a 4px red dot in
   an unlabelled icon column at the far right of a seven-column table — the
   loudest fact on the row drawn as the quietest mark on it. Equipment
   inspections was its only consumer; that column is gone and the flag now
   rides as a real badge in the leading Status column, beside Unrecognized.
   If a dot ever comes back, it should come back next to what it describes. */

/* One or two state badges in a cell. A row can be both unrecognized and
   flagged, so they sit together and wrap rather than stretching the column.

   This is an inner div, never the <td>. A flex <td> under
   `table-layout: auto` loses its intrinsic width: the column resolves to a
   sliver and the badges shrink to one letter each. flex-shrink: 0 is the
   belt to that braces — a badge states a fact and must never be clipped to
   fit. */
.data-table .cell-status {
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-1);
}
.data-table .cell-status > .badge { flex-shrink: 0; }

.data-table .col-spacer, .data-table .td-spacer {
  width: 100%; padding: 0; border-bottom: var(--bw) solid var(--border);
}
/* Kept as an explicit alias for a cell inside a non-fit column that must
   still never break a value mid-way. */
.data-table .td-nowrap, .data-table .col-nowrap { white-space: nowrap; }

.data-table th.sortable { cursor: pointer; user-select: none; white-space: nowrap; transition: color 100ms, background 100ms; }

/* ── Column-header filters ───────────────────────────────────────────────
   The table view's filter surface. A header owns two actions now: the label
   sorts, the funnel filters — so they need distinct targets, and the funnel
   is a real button that stops the click reaching the <th>.

   Only in table view, and table view is desktop-only (§8), so this whole
   control disappears on a phone along with the table it belongs to. The
   filter STRIP is the card view's surface, and it is what a phone gets. */
.col-filter {
  display: inline-flex; align-items: center; justify-content: center;
  width: 18px; height: 18px; margin-left: var(--space-1); padding: 0;
  border: none; background: none; cursor: pointer;
  color: var(--text-tertiary); border-radius: var(--r-check);
  vertical-align: middle; transition: color var(--t-fast), background var(--t-fast);
}
.col-filter:hover { color: var(--text-primary); background: var(--bg-sunken); }
.col-filter svg { width: 12px; height: 12px; display: block; }
/* An active filter is the exception, so it takes the brand colour — and the
   header goes with it, because a column quietly hiding rows must say so from
   across the room. Losing that is how a reader concludes the data is gone. */
.col-filter.active { color: var(--wollam-navy); }
.data-table th.filtered { color: var(--wollam-navy); }

/* Appended to <body>, positioned fixed — see column-filter.js for why it
   cannot live inside the <th>. z-index sits above the drawer's backdrop
   scale (§3: 60/61) so a menu opened over a table stays usable. */
.filter-menu {
  position: fixed; z-index: 70;
  min-width: 200px; max-width: 280px;
  background: var(--bg-surface); border: var(--bw) solid var(--border);
  border-radius: var(--r-container); box-shadow: var(--shadow-drawer);
  padding: var(--space-2);
}
.filter-menu-search { width: 100%; margin-bottom: var(--space-2); }
.filter-menu-list { max-height: 260px; overflow-y: auto; }
.filter-menu-option {
  display: flex; align-items: center; gap: var(--space-2);
  padding: 6px var(--space-2); border-radius: var(--r-control);
  font-size: var(--t-compact); cursor: pointer; user-select: none;
}
.filter-menu-option:hover { background: var(--bg-sunken); }
.filter-menu-option span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.filter-menu-empty {
  padding: var(--space-3) var(--space-2); font-size: var(--t-compact);
  color: var(--text-secondary); text-align: center;
}
.filter-menu-foot {
  display: flex; justify-content: flex-end;
  margin-top: var(--space-2); padding-top: var(--space-2);
  border-top: var(--bw) solid var(--border-subtle);
}
.data-table th.sortable:hover { color: var(--text-primary); background: var(--bg-canvas); }
.data-table th.sorted { color: var(--wollam-navy); }
.data-table th .sort-arrow { display: inline-block; width: 9px; margin-left: 5px; font-size: var(--t-nano); vertical-align: middle; }

/* Pagination footer for a client-paginated .table-frame. Graduated out of
   incidents.html and equipment.html, the second converted consumer
   (governance rule 3) — both defined it identically once tokenized. */
.table-pagination {
  display: flex; align-items: center; justify-content: flex-end;
  gap: var(--space-3); padding: var(--space-3) var(--space-5);
  border-top: var(--bw) solid var(--border);
  font-size: var(--t-compact); color: var(--text-secondary);
}
.table-pagination button {
  padding: var(--space-2) var(--space-3); border: var(--bw) solid var(--border);
  border-radius: var(--r-control); background: var(--bg-surface);
  color: var(--text-primary); cursor: pointer; font-size: var(--t-compact); font-family: inherit;
}
.table-pagination button:hover:not(:disabled) { background: var(--bg-canvas); }
.table-pagination button:disabled { opacity: .4; cursor: not-allowed; }

/* Sticky-header scroll region — a data table docked below page furniture
   (page-head, KPI strip, filter strip, tabs, etc.) that must keep that
   furniture visible while only the table body scrolls. Opt-in modifier,
   not the base .table-frame behaviour: most tables want ordinary document
   scroll (the base rule's overflow-y stays hidden — see the comment
   above), and forcing every table into a fixed-height box would be wrong
   for e.g. the Incidents table below, which already caps itself at 25
   rows/page.

   Graduated out of projects.html and users.html into wollam.css at Phase 3
   Task 10 (observations.html, the third converted consumer) — governance
   rule 3. Both pre-conversion definitions carried the identical
   `max-height: calc(100vh - var(--header-h) - var(--space-6) -
   var(--space-5) - 70px)`. The 70px had no cited source: it was really
   "however much page furniture sits above THIS table", a quantity that
   differs by page (a bare page-head on projects/users vs. a KPI strip +
   tab switcher + filter strip on incidents' Action Items panel vs. a
   filter-bar + filter-controls row on observations) and only happened to
   look right on the two pages that first guessed it.

   Rather than re-derive that sum from component tokens on every page (a
   second source of silent drift the moment any of those components
   changes height), each page instead measures its own `.table-frame`'s
   real distance from the top of the viewport
   (`getBoundingClientRect().top`) in a browser at a representative width,
   and sets that as `--table-frame-top` on its own `.table-frame.is-scroll-
   region`. The rule below turns it into a max-height leaving one
   `--space-6` of breathing room at the bottom, which was the original
   calc's intent. See each page's page_css comment for its measured value
   and how it was taken, and phase3-task-10-report.md for the full
   methodology. Unset falls back to 0px, which just makes the frame
   shorter than ideal rather than broken. */
/* Long tables scroll with the PAGE, not inside their own box.
   The frame used to cap itself at `100vh - <a number each page measured by
   hand>` and scroll internally, which meant a nested scrollbar beside the
   page's own, and four brittle constants (187px, 235px, 161px, 384px) that
   every change to a page header silently invalidated — including the header
   changes in this same pass.

   Now the header row is sticky against the app bar instead. Same outcome
   (column headers stay visible on a 484-row table), one scrollbar, and no
   measured magic numbers to go stale. `top` is the app bar's own height, so
   the two dock together rather than overlapping. */
.table-frame.has-sticky-header thead th {
  position: sticky; top: var(--header-h); z-index: 2;
  background: var(--bg-surface);
}
/* Between 901px and 1400px the frame takes `overflow-x: auto` (above), and
   the moment it does it becomes the sticky containing block — so
   `top: var(--header-h)` stops meaning "docked under the app bar" and
   starts meaning "56px down inside this box". Measured at 1335px on both
   list pages: the header row rendered at y=244 while the first data row sat
   at y=227, i.e. the first row of every table was hidden behind the header
   labelling it.

   Inside that range the frame IS the scrollport, so the header docks at its
   own top edge. Above 1400px nothing changes: the frame is
   `overflow: visible`, the viewport is the containing block, and
   --header-h is still the right offset.

   This rule must stay HERE, after the base rule it overrides. In the
   `max-width: 1400px` block above — which comes earlier in this file — it
   is silently reset, because a media query adds no specificity. */
@media (max-width: 1400px) and (min-width: 901px) {
  .table-frame.has-sticky-header thead th { top: 0; }
}
/* Anything scrolled to programmatically — an anchor, a focused row, a
   browser find — must clear BOTH sticky layers, or it lands underneath them
   and looks clipped. The app bar and the header row are the two things
   pinned to the top, so the page reserves exactly their combined height.
   Without this the first visible row sits behind the header row it is
   supposed to be labelled by. */
html {
  scroll-padding-top: calc(var(--header-h) + 44px);
}

/* --- 8. Card / list row ------------------------------------------------- */
.card {
  background: var(--bg-surface); border: var(--bw-card) solid var(--border);
  border-radius: var(--r-container); padding: var(--space-4);
  box-shadow: var(--shadow-card);
}

/* Card grid — the shared review-card layout: a responsive multi-column
   grid collapsing to one column on a phone. Graduated out of
   observations.html and equipment_inspections.html, the second converted
   consumer — governance rule 3. Only the three properties that were
   byte-identical in both graduate here (grid-template-columns/gap/the
   600px column collapse); the mechanism each page uses to show or hide the
   grid used to differ per page — a view-toggle on observations.html vs a
   plain breakpoint on equipment_inspections.html. Both now use the one
   contract below. */
.card-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
  gap: var(--space-4);
}
@media (max-width: 600px) {
  .card-grid { grid-template-columns: 1fr; }
}

/* ── Table-or-cards: one contract, both review pages ─────────────────────
   A review list renders the same records two ways. Whichever pane carries
   `.active` is the one on screen; the page's JS owns that class and nothing
   else about the switch.

   The choice is DESKTOP-ONLY, and the media query below is what makes that
   true rather than a convention someone has to remember. A data table on a
   390px screen either scrolls sideways or clips its own columns, and §4
   forbids both outright — so below the breakpoint the cards show and the
   table hides no matter what is stored, and the toggle itself disappears
   rather than offering a choice that does nothing.

   That split is also why the stored preference can safely default to the
   table: "table by default" only ever describes a desktop, because a phone
   never reads the preference at all. Before this, observations.html toggled
   at EVERY width, so a superintendent could be handed a sideways table on a
   phone by a choice made months earlier on a laptop.

   Specificity, not !important: each mobile rule matches the desktop rule it
   overrides selector-for-selector and simply comes later in this file. */
.card-grid:not(.active) { display: none; }
.view-pane { display: none; }
.view-pane.active { display: block; }

.view-toggle { display: flex; }
.view-toggle .btn { border-radius: 0; margin-left: -1.5px; padding: 0 var(--space-3); }
.view-toggle .btn:first-child { border-radius: var(--r-control) 0 0 var(--r-control); margin-left: 0; }
.view-toggle .btn:last-child { border-radius: 0 var(--r-control) var(--r-control) 0; }
.view-toggle .btn svg { width: 16px; height: 16px; }
.view-toggle .btn.active {
  background: var(--wollam-navy); color: var(--text-on-navy); border-color: var(--wollam-navy);
}

/* Controls that only make sense over a card grid — the filter dropdowns and
   the sort dropdown, since a card has no column header to carry either.
   They follow the same `.active` flag as the panes, so they cannot drift
   out of step with what is actually on screen.

   This MUST be CSS rather than an inline display toggle in setView(). The
   stored preference is "table" for most desktop users, and on a phone the
   rules below force the cards anyway — so a JS toggle keyed off the
   preference would hide the strip on exactly the device where it is the
   only filter surface there is. */
.view-card-only:not(.active) { display: none; }

@media (max-width: 900px) {
  .card-grid:not(.active) { display: grid; }
  .view-pane.active { display: none; }
  .view-toggle { display: none; }
  /* Cards are forced here, so their controls come back whatever is stored. */
  .view-card-only:not(.active) { display: flex; }
}

/* Interactive review card — .card above, plus a click affordance and a 4px
   status-colour left border. Graduated out of observations.html and
   equipment_inspections.html, the second converted consumer — governance
   rule 3. equipment_inspections.html's page_css never actually defined the
   base `.obs-card` at all: it only styled `.obs-card.card-flagged`/
   `.card-unverified` (border-left-color only, no width/style), assuming the
   base rule shipped from wherever `.data-table` ships from — it doesn't,
   page_css is page-scoped to the template that declares it, so this page's
   mobile card list rendered with no background, border, radius, padding or
   gap at all, and the flagged/unrecognized colour coding was invisible (a
   border-left-color with no border-left-width/style renders nothing). That
   is the defect this graduation fixes. The base rule below establishes
   `border-left: 4px solid transparent` precisely so a page-local rule can
   override just the colour and have it actually show, the same pattern
   observations.html's own `.status-*` full-shorthand overrides already
   satisfy on their own. Border width uses --bw-card (the card-edge token
   that thickens to 1.5px on mobile — §9/§14), matching every other `.card`
   consumer, in place of observations.html's original bare --bw; `.obs-card`
   had simply never been reconciled against that token before now. The
   status-colour variants themselves (`.status-*`, observations.html's own
   naming; `.card-flagged`/`.card-unverified`, equipment_inspections.html's
   own naming) stay page-local — neither page shares the other's class
   names or colour mapping. */
.obs-card {
  background: var(--bg-surface); border: var(--bw-card) solid var(--border);
  border-left: 4px solid transparent;
  border-radius: var(--r-container); padding: var(--space-4);
  cursor: pointer; transition: border-color var(--t-fast), box-shadow var(--t-fast), transform 100ms;
  display: flex; flex-direction: column; gap: var(--space-3);
}
.obs-card:hover { border-color: var(--wollam-navy); box-shadow: var(--shadow-raised); transform: translateY(-1px); }
/* The record the drawer is currently showing. The card grid needed the same
   "you are here" mark the table row has had (.data-table tbody tr.selected,
   §7) ever since the drawer learned to walk between records — without it,
   pressing down in card view moves the drawer while nothing on the page
   says which card you are on. Same --bg-selected fill, plus the navy edge,
   since a card has no first cell to carry a marker bar. */
.obs-card.selected {
  background: var(--bg-selected); border-color: var(--wollam-navy);
}

/* An .obs-card's top row (a date/timestamp on the left, a status tag or
   badge on the right) and the date text itself. Graduated out of
   observations.html and equipment_inspections.html, the second converted
   consumer — governance rule 3, found by the class-resolution checker
   (rule 5), not by eye: equipment_inspections.html's buildCard() has
   always built this same row/date shape (`class: 'card-top'`, `class:
   'card-date'`) but neither rule had ever been defined anywhere it could
   resolve from — only in observations.html's own page-scoped `<style>`
   block. Its mobile cards have been rendering that row unstyled (no
   space-between layout, no muted caption colour on the date) since the
   page converted. */
.card-top { display: flex; justify-content: space-between; align-items: center; }
.card-date { font-size: var(--t-caption); color: var(--text-secondary); }

/* A card's identifying line and its supporting line. Graduated out of
   equipment_inspections.html 2026-08-06 when incidents.html became the
   second consumer (governance rule 3), again caught by the class-resolution
   checker rather than by eye — incidents.html's card list referenced both
   and they resolved to nothing, which renders unstyled rather than as an
   error. `.card-unit-desc` is the record's own name (an equipment
   description, an incident type); `.card-sub` is the muted line beneath it
   (who and where). */
.card-unit-desc { font-size: var(--t-body); font-weight: 500; color: var(--text-primary); }
.card-sub { font-size: var(--t-body); color: var(--text-secondary); }

/* The record's own narrative, on the card. Graduated out of
   observations.html 2026-08-19 when equipment_inspections.html became the
   second consumer (governance rule 3) — the Findings column added to that
   table needed the same field on the surface that REPLACES the table below
   900px, and a phone that drops a column the desktop just gained is the
   card/table parity defect this app has fixed twice already.

   Clamped to three lines, not truncated to one: a card has the vertical
   room a table row does not, and one line of a condition note ("Hydraulic
   leak at the left rear cylinder, unit tagged out and…") is rarely the
   sentence that matters. Three is where the height stops being an outcome
   of the type scale — see the Tags cap in observations.html for the same
   argument in the horizontal axis.

   -webkit-line-clamp with -webkit-box is still the only cross-browser way
   to clamp at a line count; `line-clamp` proper is not shipping widely
   enough to drop the prefix. */
.card-desc {
  font-size: var(--t-body); line-height: 1.5; color: var(--text-primary);
  display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical;
  overflow: hidden;
}

/* Severity as an .obs-card's left edge. These MUST exist as their own
   compound rules: §6's bare `.sev-*` are defined ~240 lines above
   `.obs-card`, which sets `border-left: 4px solid transparent` at the same
   specificity — so on a card the later rule wins and every severity border
   renders invisible. Found 2026-08-06 by reading getComputedStyle on a real
   page (it returned rgba(0,0,0,0)), not by looking at it: a transparent 4px
   edge and a grey 4px edge are hard to tell apart in a screenshot. Same
   failure mode as `.obs-card.card-flagged` — see equipment_inspections.html.
   Colours follow §5: LTI purple, Recordable/First Aid red, Near Miss amber,
   everything else grey. */
.obs-card.sev-lti        { border-left-color: var(--status-critical); }
.obs-card.sev-recordable { border-left-color: var(--status-danger); }
.obs-card.sev-nearmiss   { border-left-color: var(--status-warning); }
.obs-card.sev-other      { border-left-color: var(--status-neutral); }

/* .detail-section/-title — a stack of .card sections with an uppercase
   eyebrow title, graduated out of incident_detail.html and
   banner_editor.html, the second converted consumer (governance rule 3).
   Both pre-conversion definitions padded/shadowed the card itself at 20px/
   var(--shadow-sm) — reconciled to the shared .card's 16px/--shadow-card
   above (the same normalization incident_detail.html's own conversion
   already made; banner_editor.html now matches it instead of carrying a
   second, slightly-larger card treatment). Only the page-local
   margin-bottom and the title's typography are section-specific. */
.detail-section { margin-bottom: var(--space-4); }
.detail-section-title {
  font-size: var(--t-compact); font-weight: 600; color: var(--text-secondary);
  text-transform: uppercase; letter-spacing: .04em; margin-bottom: var(--space-3);
}
.list-row {
  display: flex; align-items: center; gap: var(--space-3);
  padding: var(--space-4); min-height: 56px;
  border-bottom: var(--bw) solid var(--border-subtle);
}
.list-row:last-child { border-bottom: none; }
.dot { width: 8px; height: 8px; border-radius: var(--r-pill); flex-shrink: 0; }

/* KPI tile — one number, no sparkline, no delta, no icon */
.kpi {
  background: var(--bg-surface); border: var(--bw) solid var(--border);
  border-radius: var(--r-container); padding: var(--space-3) var(--space-4);
}
.kpi-label {
  font-size: var(--t-micro); font-weight: 600; color: var(--text-secondary);
  text-transform: uppercase; letter-spacing: .04em;
}
.kpi-value {
  font-size: var(--t-subhead); font-weight: 700; font-variant-numeric: tabular-nums;
  margin-top: 2px;
}
.kpi-value.is-exception { color: var(--status-danger); }  /* color = exception, no extra words */

/* --- 9. Form fields ----------------------------------------------------- */
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: var(--space-4); }
.field label { font-size: var(--t-compact); font-weight: 600; color: var(--text-primary); }
.field .hint { font-size: var(--t-caption); color: var(--text-secondary); }
.input, .select, .textarea {
  width: 100%; min-height: var(--btn-h); padding: var(--space-2) var(--space-3);
  border-radius: var(--r-control); border: var(--bw) solid var(--border);
  background: var(--bg-surface); color: var(--text-primary);
  font-family: inherit; font-size: var(--t-body);
  -webkit-appearance: none; appearance: none;
}
/* appearance:none above drops the native arrow on every browser so a
   select never renders two chevrons at once (one native, one custom) —
   the bug observe.html hit when it drew its own on top of the untouched
   native control. Supplying the replacement here, once, means every
   converted page's select looks the same without a page-local override;
   observe.html used to carry this itself (colour #64748B, --text-
   secondary) and equipment_inspect.html's pre-conversion original carried
   a near-duplicate (#94A3B8, --text-tertiary) — reconciled to the latter,
   which matches an existing token exactly. Data-URI colours can't
   reference var() (custom properties don't resolve inside a url() string),
   so the token's hex is inlined here; this file is the token source and is
   exempt from the raw-value check that applies to templates. */
.select {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2394A3B8' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right 14px center;
  background-size: 16px;
  padding-right: 40px;
}
.textarea { min-height: 88px; resize: vertical; line-height: 1.6; }
.input:focus, .select:focus, .textarea:focus {
  outline: none; border-color: var(--wollam-navy); box-shadow: var(--focus-ring);
}
.input::placeholder { color: var(--text-tertiary); }
.field.is-invalid .input, .field.is-invalid .select, .field.is-invalid .textarea {
  border-color: var(--status-danger);
}
.field .error {
  font-size: var(--t-caption); font-weight: 600; color: var(--status-danger-text);
}
.field .ok { font-size: var(--t-caption); font-weight: 600; color: var(--status-success-text); }
.input:disabled, .select:disabled { background: var(--bg-sunken); color: var(--text-disabled); }
.checkbox {
  width: 16px; height: 16px; border-radius: var(--r-check);
  border: 1.5px solid var(--border-strong); flex-shrink: 0;
}
.checkbox.is-checked { background: var(--status-success); border-color: var(--status-success); }
.checkbox.is-overdue { border-color: var(--status-danger); }

/* Grouped-fields grid: 3 or 2 responsive columns, collapsing to 1 below
   700px (a page's own choice of breakpoint, distinct from the shared
   900px mobile floor above). Graduated out of incident_detail.html and
   incident_form.html, the second converted consumer (governance rule 3) —
   both defined it identically (incident_form's pre-conversion .form-row/
   .form-row-2 also carried their own margin-bottom: 16px, made redundant
   here by .field's own margin-bottom on every grid item; dropped, not
   reconciled in). */
.detail-grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: var(--space-4); }
.detail-grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-4); }
@media (max-width: 700px) {
  .detail-grid, .detail-grid-2 { grid-template-columns: 1fr; }
}

/* --- 10. Drawer + backdrop --------------------------------------------- */
/* Contract: hidden by default; a consumer opens it by adding `.open` to
   BOTH #panel-backdrop and #panel (nothing renders until that class is
   added — no page-local display/visibility toggling needed). Close the
   same way: remove `.open` from both.
     document.getElementById('panel-backdrop').classList.add('open');
     document.getElementById('panel').classList.add('open');
   Previously `.panel`/`.panel-backdrop` defined shape only (position,
   z-index, flex) with no hidden state at all, so every one of the three
   consumers had to invent its own show/hide. Two of the three
   (observations.html, equipment_inspections.html) had already converged
   on an identical opacity/visibility + transform/transition `.open`
   pattern, independently, as page-local overrides — this graduates that
   exact pattern here instead of a fourth reinvention, and both page-local
   copies are deleted in favour of it. The third (incidents.html's
   #ca-drawer) used plain inline `style="display:none"` + JS
   style.display toggling; its markup and JS were updated to the same
   `.open` convention rather than left on a second mechanism.
   This is the fix for a defect that shipped TWICE during conversion — a
   drawer visible on page load, caught only by a live screenshot each
   time (see the changelog entry below and each page's own former
   comment). Giving the shared component its own real default-hidden
   state removes the chance for a third implementer to ship it a third
   time. */
.panel-backdrop {
  position: fixed; inset: 0; background: rgba(0, 0, 0, .30); z-index: 60;
  opacity: 0; visibility: hidden;
  transition: opacity .25s, visibility .25s;
}
.panel-backdrop.open { opacity: 1; visibility: visible; }
.panel {
  position: fixed; top: 0; right: 0; bottom: 0; width: var(--drawer-w); max-width: 100vw;
  background: var(--bg-surface); box-shadow: var(--shadow-drawer); z-index: 61;
  display: flex; flex-direction: column;
  transform: translateX(100%);
  transition: transform .3s ease;
}
.panel.open { transform: translateX(0); }
/* RETIRED as a layout, kept as a rule. Both review drawers
   (observations, equipment_inspections) now open with .panel-navbar +
   .panel-title-row below, which carries a title, a subtitle, a position
   counter and two thumb-through buttons — everything .panel-header's
   single "title on the left, close on the right" row cannot. Nothing in
   templates/ renders .panel-header today. It stays defined as the plain
   variant for a drawer that genuinely has one title and one close button
   and no record to navigate between; delete it if that never arrives. */
.panel-header {
  padding: var(--space-3) var(--space-5); border-bottom: var(--bw) solid var(--border);
  display: flex; align-items: center; justify-content: space-between; gap: var(--space-3);
}

/* ── Drawer nav bar: thumb-through chrome ────────────────────────────────
   Reviewing records is a thumb-through task, not a lookup task — open the
   first, read it, act or don't, press down. The bar carries prev/next, the
   position counter, a flag toggle and close.

   Graduated out of equipment_inspections.html when observations.html
   became the second consumer (governance rule 3). The equipment page keeps
   its element ids for its JS; only the styling moved to these classes.

   Shape borrowed from the control tokens rather than invented: the buttons
   are --control-h square, the toggle is a --r-control pill. */
.panel-navbar {
  display: flex; align-items: center; gap: var(--space-2);
  padding: var(--space-3) var(--space-5); border-bottom: var(--bw) solid var(--border);
  flex-shrink: 0;
}
.panel-nav-btn {
  width: var(--control-h); height: var(--control-h); border-radius: var(--r-control);
  border: var(--bw) solid var(--border); background: var(--bg-surface);
  color: var(--text-secondary); cursor: pointer; font-family: inherit;
  display: flex; align-items: center; justify-content: center;
  /* No exact token for the original 15px glyph — halfway between --t-body
     (14px) and --t-lead (16px). Mapped up so the arrow reads clearly. */
  font-size: var(--t-lead);
  transition: all var(--t-fast);
}
.panel-nav-btn:hover:not(:disabled) { border-color: var(--border-strong); color: var(--text-primary); }
.panel-nav-btn:disabled { opacity: .35; cursor: not-allowed; }
/* Tabular figures: "9 of 53" -> "10 of 53" must not shift the bar. */
.panel-position { font-size: var(--t-compact); color: var(--text-secondary); font-variant-numeric: tabular-nums; }
.panel-flag-toggle {
  padding: 6px var(--space-3); border-radius: var(--r-control); font-size: var(--t-compact); font-weight: 500;
  border: var(--bw) solid var(--border); background: var(--bg-surface);
  color: var(--text-secondary); cursor: pointer; font-family: inherit;
  margin-left: auto; transition: all var(--t-fast);
}
.panel-flag-toggle:hover { border-color: var(--border-strong); }
.panel-flag-toggle.active {
  background: var(--status-info-tint); color: var(--status-info); border-color: var(--status-info-tint);
}

/* Title row: what this record IS. Read-only by definition — the two facts
   that identify a record (its name and when/where it came from) sit here
   rather than in the Overview grid, so the grid holds only fields that
   could change. */
.panel-title-row { padding: var(--space-4) var(--space-5); border-bottom: var(--bw) solid var(--border); flex-shrink: 0; }
.panel-title {
  font-size: var(--t-subhead); font-weight: 600;
  display: flex; align-items: center; gap: var(--space-2);
}
.panel-subtitle { font-size: var(--t-compact); color: var(--text-secondary); margin-top: var(--space-1); }

/* Long-form free text inside a drawer — condition notes, a description,
   what was done about it. Deliberately NOT the boxed .readout below: this
   text can run to paragraphs, and five boxes stacked down a drawer read as
   five controls rather than as prose. .readout stays for short read-only
   values elsewhere. Graduated out of equipment_inspections.html with
   observations.html as the second consumer. */
.panel-desc {
  font-size: var(--t-body); line-height: 1.6; color: var(--text-primary);
  white-space: pre-wrap; word-break: break-word;
}
.panel-body { flex: 1; overflow-y: auto; padding: var(--space-5); }
.panel-footer {
  padding: var(--space-3) var(--space-5); border-top: var(--bw) solid var(--border);
  display: flex; align-items: center; justify-content: space-between; gap: var(--space-2);
}
/* .panel-section: the spacing wrapper around each block inside a review
   drawer's .panel-body (Overview / Condition / Photos / Unrecognized, etc.)
   — just a bottom margin so consecutive sections don't run together.
   Graduated out of observations.html and equipment_inspections.html, the
   second converted consumer — governance rule 3, found by the class-
   resolution checker (scripts/check_tokens.py rule 5), not by eye:
   equipment_inspections.html's drawer-building JS (buildOverviewSection/
   buildConditionSection/buildPhotosSection/buildUnrecognizedSection) has
   always built each section as `el('div', { class: 'panel-section' })`, but
   `.panel-section` itself was only ever defined in observations.html's own
   page-scoped `<style>` block. Its drawer sections have been rendering with
   no spacing between them since the page converted. */
.panel-section { margin-bottom: var(--space-5); }
.panel-section-label {
  font-size: var(--t-micro); font-weight: 700; text-transform: uppercase;
  letter-spacing: .08em; color: var(--text-secondary); margin-bottom: var(--space-2);
}
.panel-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-3) var(--space-4); }
.panel-field-label {
  font-size: var(--t-micro); font-weight: 600; color: var(--text-secondary);
  text-transform: uppercase; letter-spacing: .04em;
}
.panel-field-value { font-size: var(--t-body); margin-top: 2px; }
/* Record history — one line per change, in a drawer. Deliberately plain:
   it is reference material you scan when something looks wrong, not part of
   the review itself, so it reads quieter than everything above it. */
.history-list { display: flex; flex-direction: column; gap: var(--space-2); }
.history-item { font-size: var(--t-compact); line-height: 1.5; }
.history-what { color: var(--text-primary); }
.history-what .history-field { font-weight: 600; }
/* An old value is what it is NOT any more. Struck through rather than
   merely grey, so the direction of the change survives a glance. */
.history-old { color: var(--text-tertiary); text-decoration: line-through; }
.history-who { color: var(--text-secondary); font-size: var(--t-caption); }
.history-empty { font-size: var(--t-compact); color: var(--text-secondary); }

.readout {
  font-size: var(--t-body); line-height: 1.7; background: var(--bg-canvas);
  border: var(--bw) solid var(--border); border-radius: var(--r-control); padding: var(--space-3);
}

/* --- 11. Photo / attachment grid --------------------------------------- */
.photo-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--space-2); }
.photo-grid > * {
  aspect-ratio: 1; border-radius: var(--r-control); border: var(--bw) solid var(--border);
  overflow: hidden; background: var(--bg-sunken);
}
.photo-grid img { width: 100%; height: 100%; object-fit: cover; display: block; }
.photo-more {
  border-style: dashed; border-color: var(--border-strong);
  display: flex; align-items: center; justify-content: center;
  font-size: var(--t-micro); color: var(--text-secondary);
}

/* --- 12. Empty state — heading, one sentence, the action ---------------- */
.empty { padding: var(--space-8) var(--space-6); text-align: center; color: var(--text-secondary); }
.empty h3 { font-size: var(--t-lead); font-weight: 600; margin-bottom: var(--space-1); }
.empty p {
  font-size: var(--t-compact); color: var(--text-secondary);
  max-width: 380px; margin: 0 auto var(--space-4);
}
/* .empty inside a <td> (projects.html/users.html's "No projects yet"/
   "Failed to load" rows) lost its own padding to a specificity collision:
   `.data-table td` (§7) is a class+type selector, (0,1,1), which beats
   `.empty` alone, (0,1,0), regardless of source order — the cell fell back
   to `.data-table td`'s 10px/16px table padding, leaving only .empty's
   text-align:center visibly applied. `.data-table td.empty`, (0,2,1),
   restores the intended empty-state padding without !important. */
.data-table td.empty { padding: var(--space-8) var(--space-6); }

/* Skeleton shimmer — a LOADING placeholder, not an empty state: .empty
   communicates "there is genuinely nothing here," heading + sentence +
   action; a page still waiting on its first fetch has no heading/sentence/
   action to show yet and needs a visually distinct in-progress signal
   instead. Graduated out of communications.html and dashboard.html, which
   had defined the identical gradient/size/timing shimmer twice under two
   different @keyframes names (governance rule 3) — reconciled to one name,
   `skeleton-shimmer`. communications.html's base `.skeleton` additionally
   set a default `height: 120px`, dead in practice (every consumer on both
   pages sizes itself via an inline `style="height:…"` or the `.skeleton-
   value` modifier, which both win over a class default) — dropped rather
   than carried forward. */
.skeleton {
  background: linear-gradient(90deg, var(--bg-sunken) 25%, var(--bg-surface) 50%, var(--bg-sunken) 75%);
  background-size: 200% 100%;
  animation: skeleton-shimmer 1.5s infinite;
  border-radius: var(--r-control);
}
@keyframes skeleton-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }

/* --- 13. Toast + confirm dialog — never alert()/confirm()/prompt() ------ */
.toast {
  position: fixed; bottom: var(--space-6); left: 50%;
  transform: translateX(-50%) translateY(8px);
  display: flex; align-items: center; gap: var(--space-2);
  padding: var(--space-3) var(--space-4); border-radius: var(--r-control);
  background: var(--text-primary); color: #fff; font-size: var(--t-compact); font-weight: 500;
  box-shadow: var(--shadow-raised); z-index: 80; max-width: calc(100vw - 32px);
  opacity: 0; pointer-events: none; transition: opacity var(--t-fast), transform var(--t-fast);
}
.toast.visible { opacity: 1; transform: translateX(-50%) translateY(0); }
.toast.is-error { background: var(--status-danger); }
.toast.is-success { background: var(--status-success); }

/* Modal — one implementation. Replaces the .modal* / .confirm-* split.
   .modal is the form size; .modal-sm is the destructive-confirm size. */
.modal-backdrop {
  position: fixed; inset: 0; background: rgba(0, 0, 0, .30); z-index: 70;
  display: none; align-items: center; justify-content: center; padding: var(--space-4);
}
.modal-backdrop.open { display: flex; }
.modal {
  width: 480px; max-width: 100%; max-height: calc(100vh - 64px); overflow-y: auto;
  background: var(--bg-surface); border-radius: var(--r-container);
  box-shadow: var(--shadow-raised); padding: var(--space-5);
}
.modal-sm { width: 400px; }
.modal-title { font-size: var(--t-lead); font-weight: 700; margin-bottom: var(--space-2); }
.modal-text {
  font-size: var(--t-compact); color: var(--text-secondary); margin-bottom: var(--space-5);
  white-space: pre-line;
}
.modal-actions { display: flex; justify-content: flex-end; gap: var(--space-2); }

/* --- 14. Mobile — ≤900px ----------------------------------------------- */
@media (max-width: 900px) {
  :root {
    --bw-card: 1.5px;        /* thicker card edge reads at arm's length, in sun */
    --btn-h: var(--btn-h-touch);
    --control-h: var(--control-h-touch);
  }
  body { font-size: var(--t-lead); }

  .sidebar { position: fixed; inset: 0 auto 0 0; z-index: 50; transform: translateX(-100%);
             transition: transform 200ms ease; overflow-y: auto; }
  body.sidebar-open .sidebar { transform: none; }
  /* The drawer is off-canvas here, so .main must reclaim the width the
     desktop rule reserved for a sidebar that is no longer in the layout —
     without this every mobile page renders 240px short. */
  .main { margin-left: 0; }
  .content { padding: var(--space-3); padding-bottom: calc(var(--space-3) + env(safe-area-inset-bottom)); }

  /* Mobile menu trigger — state driven by body.sidebar-open (see mobile.js).
     z-index 51: above the sidebar (its close icon stays tappable while the
     menu is open), below the drawer/modal (see the scale in §3). */
  .mobile-menu-btn {
    display: flex; align-items: center; justify-content: center;
    position: fixed; top: var(--space-2); left: var(--space-2); z-index: 51;
    width: 44px; height: 44px; border-radius: var(--r-control);
    border: var(--bw) solid var(--border); background: var(--bg-surface);
    color: var(--text-primary); cursor: pointer;
  }
  .mobile-menu-btn svg { width: 22px; height: 22px; }
  .mobile-menu-btn .icon-close { display: none; }
  body.sidebar-open .mobile-menu-btn .icon-menu { display: none; }
  body.sidebar-open .mobile-menu-btn .icon-close { display: block; }

  /* Open state sits over the sidebar itself — shift clear of the logo and
     drop the white button chrome so only the close icon reads (mirrored
     the equivalent contract in static/css/mobile.css, now deleted —
     every template gets this treatment from here). */
  body.sidebar-open .mobile-menu-btn {
    left: var(--sidebar-w); background: transparent; border: none; box-shadow: none;
  }

  .sidebar-overlay {
    position: fixed; inset: 0; background: rgba(0, 0, 0, .30); z-index: 49;
  }
  body.sidebar-open .sidebar-overlay { display: block; }

  /* Must stay after the base .header padding shorthand (§3) — media
     queries add no specificity, so an earlier rule here would be reset. */
  .header { padding-left: 60px; }

  /* Every input at 16px or iOS zooms the viewport on focus. Non-negotiable.
     The 44px min-height floor is for single-line controls only — applying
     it to .textarea as well (same specificity, later in the cascade than
     §9's 88px) used to win and collapse a textarea down to ~2 lines on a
     phone. Textareas keep the 16px font (still needed for the iOS zoom
     fix) but not the height floor, so §9's --and any page-local-- height
     wins instead. */
  .input, .select, .textarea, .search-box, .filter-select, .filter-pill {
    font-size: var(--t-lead);
  }
  .input, .select, .search-box, .filter-select, .filter-pill {
    min-height: var(--control-h-touch);
  }
  .search-box { width: 100%; margin-left: 0; }

  .desktop-only { display: none !important; }

  .panel { width: 100vw; }
  .panel-grid { grid-template-columns: 1fr; }
  .panel-footer > .btn { flex: 1; }
  /* Every control in the drawer nav bar is a tap target, and this app is
     used mostly on phones. .panel-nav-btn already arrives at 44px because
     it sizes off --control-h, which this block redefines; the flag pill is
     padding-sized, so its floor has to be stated. Measured at 390px: the
     pill was 30px tall.

     The close button needs the same floor and CANNOT get it here — it is
     defined in §19, later in this file, and a media query adds no
     specificity, so a rule here would be reset by its base 32px square. It
     sits beside its own definition instead. */
  .panel-flag-toggle { min-height: var(--control-h-touch); }

  /* UNADOPTED — carried over verbatim from the canonical design system.
     No template renders this yet; not a bug if it looks unused today. */
  /* Collapse anything not needed on a phone into a 52px tappable row */
  .sheet-row {
    display: flex; align-items: center; justify-content: space-between;
    padding: var(--space-4); min-height: 52px;
    border-bottom: var(--bw) solid var(--border-subtle);
    font-size: var(--t-lead); font-weight: 600;
  }

  /* UNADOPTED — the bottom tab bar is explicitly out of scope for this
     project (see spec §8). _base.html never renders a .tabbar; these rules
     exist only because they came from the canonical source. Do not wire up
     without a product decision to add a tab bar. */
  .tabbar {
    position: fixed; left: 0; right: 0; bottom: 0; z-index: 45;
    height: calc(var(--tabbar-h) + env(safe-area-inset-bottom));
    padding-bottom: env(safe-area-inset-bottom);
    background: var(--bg-surface); border-top: var(--bw) solid var(--border);
    display: grid; grid-auto-flow: column; grid-auto-columns: 1fr;
  }
  .tabbar a {
    position: relative; display: flex; flex-direction: column;
    align-items: center; justify-content: center; gap: 3px;
    color: var(--text-secondary); font-size: var(--t-nano); font-weight: 600; text-decoration: none;
  }
  .tabbar a.active { color: var(--wollam-navy); font-weight: 700; }
  .tabbar a.active::before {
    content: ''; position: absolute; top: 0; left: 50%; transform: translateX(-50%);
    width: 26px; height: 3px; border-radius: 0 0 var(--r-indicator) var(--r-indicator); background: var(--wollam-navy);
  }
  /* ADOPTED 2026-08-05 — rendered by templates/_desktop_required.html, which
     four rung-4 features include: /users, /projects, /tag-settings and the
     banner editor. See docs/superpowers/specs/2026-08-05-mobile-degradation-
     ladder-design.md. Leave these rules inside this ≤900px block: the partial
     only ever renders inside a .mobile-only wrapper. */
  /* Rung 4 of the degradation ladder: a desktop-only screen reached on a phone.
     Explains itself and offers a way forward. Never a broken layout. */
  .desktop-required { padding: var(--space-8) var(--space-5); text-align: center; }
  .desktop-required h2 { font-size: var(--t-subhead); font-weight: 700; margin-bottom: var(--space-2); }
  .desktop-required p {
    font-size: var(--t-lead); color: var(--text-secondary); line-height: 1.6;
    max-width: 330px; margin: 0 auto var(--space-6); text-wrap: pretty;
  }
  .desktop-required .btn { width: 100%; margin-bottom: var(--space-2); }

  /* UNADOPTED — carried over verbatim from the canonical design system.
     No template renders this yet; not a bug if it looks unused today. */
  /* Offline / queued submit — never loses the entry, never blames the user. */
  /* .offline-note moved OUT of this mobile block on 2026-08-12 when it got
     its first consumers — see §17. A dropped connection is not a mobile-only
     event, and left here the note rendered as unstyled bare text on a laptop
     that lost wifi. */

  /* UNADOPTED — pairs with .tabbar, which is out of scope for this project.
     No template renders this yet; not a bug if it looks unused today. */
  .tab-badge {
    position: absolute; top: 6px; right: 22px; min-width: 17px; height: 17px; padding: 0 4px;
    border-radius: var(--r-pill); background: var(--status-warning); color: #fff;
    font-size: var(--t-nano); font-weight: 700; display: flex; align-items: center; justify-content: center;
  }
}
@media (min-width: 901px) { .mobile-only { display: none !important; } }

/* --- 15. Print — see the Wollam document standards for paper deliverables */
@media print {
  .sidebar, .header, .tabbar, .filter-strip, .panel-backdrop, .btn, .toast,
  .mobile-menu-btn, .sidebar-overlay { display: none !important; }
  body { background: #fff; font-size: 12pt; }
  .card, .table-frame, .panel { box-shadow: none; border: 1px solid #999; }
  a { color: #000; text-decoration: none; }
}

/* --- 16. Standalone document pages --------------------------------------- */
/* The navy top banner (.standalone-header*) is shared by three pages that
   are not part of the app shell: login.html, privacy_policy.html, and
   terms.html. The content column and legal-prose card below it
   (.content-wrapper, .legal-card, .effective-date, .footer-links) are used
   only by the two legal documents — login.html has no prose, just the
   banner — so those keep the "legal" name; only the banner itself was
   renamed from .legal-header* (it predates login.html's conversion and
   login.html is not a legal document). A third standalone page that needs
   the banner or the card graduates whatever it adds the same way. */
.standalone-header {
  height: var(--header-h); background: var(--wollam-navy);
  display: flex; align-items: center; padding: 0 var(--space-6);
  box-shadow: var(--shadow-card);
}
.standalone-header-left { display: flex; align-items: center; gap: var(--space-4); }
.standalone-header-logo { height: 28px; filter: brightness(0) invert(1); }
.standalone-header-title {
  font-size: var(--t-lead); font-weight: 600; color: var(--text-on-navy);
  letter-spacing: -0.01em;
}

.content-wrapper {
  max-width: 740px; margin: 40px auto; padding: 0 var(--space-6) 80px;
  line-height: 1.7;
}
/* Links inside legal prose keep their own rule rather than falling through
   to the global `a` (§2): the global default is no underline until hover,
   but legal copy underlines every link at rest — the same class of
   specificity/inheritance gap the 404 conversion hit with `a:hover`. Scoped
   to .content-wrapper, not just .legal-card, so the footer links below the
   card keep the same treatment. */
.content-wrapper a { color: var(--wollam-navy); text-decoration: underline; }
.content-wrapper a:hover { color: var(--wollam-navy-hover); }

.legal-card {
  background: var(--bg-surface); border: var(--bw) solid var(--border);
  border-radius: var(--r-container); box-shadow: var(--shadow-raised); padding: var(--space-8) 40px;
}
@media (max-width: 600px) {
  .legal-card { padding: var(--space-7) var(--space-5); }
  .content-wrapper { margin: var(--space-5) auto; }
}

/* h1/h2 pick up size and weight from the global rules in §2; only the
   properties that differ from those defaults are declared here. */
.legal-card h1 { color: var(--wollam-navy); margin-bottom: var(--space-1); }
.effective-date {
  font-size: var(--t-compact); color: var(--text-secondary);
  margin-bottom: var(--space-7);
}
.legal-card h2 {
  font-size: var(--t-subhead); font-weight: 600; color: var(--text-primary);
  margin: 28px 0 var(--space-3); padding-top: var(--space-5);
  border-top: var(--bw) solid var(--border);
}
.legal-card h2:first-of-type { border-top: none; padding-top: 0; }
.legal-card p { margin-bottom: var(--space-3); color: var(--text-secondary); }
.legal-card ul { margin: var(--space-2) 0 var(--space-4) var(--space-6); color: var(--text-secondary); }
.legal-card li { margin-bottom: 6px; }
.legal-card strong { color: var(--text-primary); font-weight: 600; }

.footer-links {
  text-align: center; margin-top: var(--space-7); font-size: var(--t-compact);
  color: var(--text-secondary);
}
.footer-links a { margin: 0 var(--space-3); }

/* --- 17. Field capture forms --------------------------------------------- */
/* Shared by the two public/field forms — observe.html (hazard reports) and
   equipment_inspect.html (equipment walk-arounds) — both reached from a
   phone with no login, both converted to this shell. Graduated here per
   governance rule 3: each rule below was defined in both templates,
   reconciled to one treatment rather than copied from either.

   .field-header is a DIFFERENT component from the app shell's .header (§3 —
   56px, white, not sticky). Different name on purpose so the two never
   collide by cascade order. Logo height stays page-local: one page uses the
   full wordmark (logo.png), the other the square mark (logo-mark.png), and
   they read correctly at different heights — everything else about the
   header (background, padding, position, the title treatment) is shared.

   .capture-grid is the interactive add-photo widget — NOT the same
   component as §11's read-only .photo-grid, which observation-review pages
   still use for display-only thumbnails. Both field forms had this named
   .photo-grid before this pass and won by page-local override; renamed to
   remove the collision, not because the two forms' designs disagreed —
   once photo-preview tiles are a fixed 64px, a plain wrapping flex row
   works for both.

   .add-photo-btn keeps only its shared box treatment here. Its content
   differs by page on purpose: equipment's button is a bare "+", observe's
   carries the user-facing label "Add Photo or Choose File" — a string that
   must survive byte for byte — so each page still sets its own
   size/typography for it locally. */
.field-header {
  background: var(--wollam-navy);
  padding: var(--space-4) var(--space-5);
  display: flex;
  align-items: center;
  gap: var(--space-3);
  position: sticky;
  top: 0;
  z-index: 10;
}
.field-header img { width: auto; filter: brightness(0) invert(1); }
.header-text h1 {
  color: var(--text-on-navy);
  font-size: var(--t-lead);
  font-weight: 600;
  line-height: 1.2;
}

/* ── Card icon ──
   The 44px tinted square that heads a card on a field page. Graduated here
   per governance rule 3: observe.html defined it for its two cards (hazard
   report, SMS signup), and field_start.html needs the identical treatment
   for its two choices -- the second converted consumer.

   Every variant is a status tint, never a new colour. Observation is amber
   because a hazard report is a warning; equipment and SMS are both navy
   because filing an inspection and subscribing to alerts are informational,
   and informational is navy (--status-info is an alias for it). There is no
   second blue. */
.card-icon {
  width: 44px; height: 44px; border-radius: var(--r-control);
  display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.card-icon svg { width: 22px; height: 22px; }
.card-icon.observe   { background: var(--status-warning-tint); color: var(--status-warning); }
.card-icon.equipment { background: var(--status-info-tint);    color: var(--status-info); }
.card-icon.sms       { background: var(--status-info-tint);    color: var(--status-info); }

/* ── Offline queue ──
   Design system §4: "Assume the signal drops. Entries are held locally and
   retried. Never lose the entry, never blame the user."

   .offline-note sits ABOVE the primary action and the button stays enabled.
   Disabling submit when the signal drops is what makes someone re-type an
   inspection twice and then stop using the tool — the tap still works, and
   the entry queues. Amber, never red: a held entry is not an error.

   Lived inside the <=900px block until 2026-08-12 (unadopted, so nobody
   noticed); a laptop that loses wifi needs it too. */
.offline-note {
  display: flex; align-items: center; gap: var(--space-2);
  padding: var(--space-3); border-radius: var(--r-control);
  background: var(--status-warning-tint); color: var(--status-warning-text);
  font-size: var(--t-caption); font-weight: 600;
}

/* The hidden file input each field form drives from a button, and the two
   shapes that button takes. All three were id rules (`#photo-input`) or bare
   `.add-photo-btn` overrides in the two page <style> blocks, which worked
   only while no document could hold both forms at once. The field landing
   now does: `#photo-input` there can only ever hide ONE of the two inputs,
   leaving the other rendered as a raw "Choose File" control mid-form, and
   the two bare `.add-photo-btn` blocks would each restyle the other page's
   button. Modifier classes instead, so each form asks for its own shape. */
.photo-input { display: none; }
/* One shape for both field forms: an icon stacked over the label "Add Photo
   or Choose File", a user-facing string that survives byte for byte.

   The equipment form used to carry a bare "+" in a fixed 64px cell
   (.add-photo-btn.is-glyph, retired 2026-08-27 when it lost its only
   consumer). Both buttons always opened the same input with the same
   attributes, but "+" said nothing about a camera, so the two read as
   different features to the person holding the phone. On an inspection the
   photo IS the finding, so that button is the last place to be terse. */
.add-photo-btn.is-labeled {
  flex-direction: column;
  padding: 0 var(--space-3);
  gap: var(--space-2);
  font-size: var(--t-caption);
  font-weight: 600;
  text-align: center;
}
.add-photo-btn.is-labeled svg { width: 22px; height: 22px; flex-shrink: 0; }

.photo-section { margin-bottom: var(--space-5); }
.photo-label {
  display: block; font-size: var(--t-compact); font-weight: 600;
  color: var(--text-secondary); margin-bottom: var(--space-2);
}
/* The optional-count qualifier ("(optional, up to 5)") reads as a lighter,
   secondary aside, not a second instance of the label itself — without
   this it inherits .photo-label's 600 weight/--text-secondary and visually
   merges into "Photos (optional, up to 5)" as one run of equally-weighted
   text. text-transform/letter-spacing are reset defensively even though
   .photo-label itself no longer sets either (see the §17 changelog) so
   this stays correct if that ever changes back. */
.photo-label span {
  font-weight: 400;
  color: var(--text-secondary);
  text-transform: none;
  letter-spacing: 0;
}
/* Restored to the pre-conversion grid (3 cols, 4 at >=400px viewport) —
   the flat 64px flex row this replaced rendered thumbnails at 62x62 on a
   390px phone, too small to identify a jobsite photo by. Grid cells size
   off the column, not a fixed px, so this stays responsive like the
   original rather than pinning one arbitrary size. */
.capture-grid {
  display: grid; grid-template-columns: repeat(3, 1fr);
  gap: 10px; margin-bottom: var(--space-2);
}
@media (min-width: 400px) {
  .capture-grid { grid-template-columns: repeat(4, 1fr); }
}
.photo-preview {
  position: relative; aspect-ratio: 1;
  border-radius: var(--r-container);
  border: 1.5px solid var(--border); background: var(--bg-sunken);
}
/* border-radius lives on the img (not overflow:hidden on the container) so
   the remove button's expanded touch target below can bleed past the
   thumbnail's edge without being clipped by the container. */
.photo-preview img {
  width: 100%; height: 100%; object-fit: cover; border-radius: inherit; display: block;
}
/* No fitting token for a translucent black scrim over arbitrary photo
   content (wollam.css has no --scrim/overlay token; its own
   .modal-backdrop rgba(0,0,0,.30) isn't exposed as a var()). Solid navy is
   the nearest brand-consistent value rather than inventing one. */
.photo-preview .remove-btn {
  position: absolute; top: 4px; right: 4px; width: 26px; height: 26px;
  background: var(--wollam-navy); color: var(--text-on-navy);
  border: none; border-radius: 50%; font-size: var(--t-lead); line-height: 1;
  cursor: pointer; display: flex; align-items: center; justify-content: center;
}
/* Touch-target floor (§ "44px is a floor"): the visible circle stays 26px
   so it doesn't swallow a ~95-145px thumbnail, but on phones its hit area
   is grown to the 44px minimum via an invisible, centered ::before — the
   standard expand-the-hit-area-not-the-glyph technique. Requires
   .photo-preview to NOT clip overflow (see above). */
@media (max-width: 900px) {
  .photo-preview .remove-btn::before {
    content: '';
    position: absolute; top: 50%; left: 50%;
    width: var(--control-h-touch); height: var(--control-h-touch);
    transform: translate(-50%, -50%);
  }
}
.add-photo-btn {
  aspect-ratio: 1;
  border: 2px dashed var(--border-strong); border-radius: var(--r-container);
  background: var(--bg-canvas); cursor: pointer;
  display: flex; align-items: center; justify-content: center;
  color: var(--text-secondary);
  transition: border-color 0.2s, background 0.2s, color 0.2s;
}
.add-photo-btn:hover, .add-photo-btn:active {
  border-color: var(--wollam-navy); background: var(--bg-sunken); color: var(--wollam-navy);
}
.photo-count { font-size: var(--t-caption); color: var(--text-secondary); text-align: right; }

.success-screen { display: none; text-align: center; }
.success-screen.visible { display: block; }

/* Submit-button loading spinner — both field forms disable their primary
   button and show a spinner in place of the label while the request is in
   flight. The original blanked the label with color:transparent and drew
   a two-tone ring: a faint full circle plus a brighter rotating arc, using
   rgba(255,255,255,.3) for the faint half. The hardened checker rejects
   raw alpha colours in a template, and there is no --scrim/translucent-
   white token to fall back on (see the .remove-btn note above), so the
   faint ring here is var(--text-on-navy) at full saturation with the
   `opacity` CSS property doing the fading instead of an alpha channel —
   same visual result, no invented colour. Two pseudo-elements because
   `opacity` fades the whole element uniformly: ::before is the dim ring,
   ::after is the bright arc (transparent on 3 sides, only the top visible)
   layered on top and spinning. */
.btn.loading {
  position: relative;
  color: transparent !important;
  pointer-events: none;
}
.btn.loading::before,
.btn.loading::after {
  content: '';
  position: absolute;
  left: 50%; top: 50%;
  width: 22px; height: 22px;
  border-radius: 50%;
  border: 3px solid transparent;
  transform: translate(-50%, -50%);
}
.btn.loading::before { border-color: var(--text-on-navy); opacity: .3; }
.btn.loading::after {
  border-top-color: var(--text-on-navy);
  animation: btn-spin .6s linear infinite;
}
@keyframes btn-spin { to { transform: translate(-50%, -50%) rotate(360deg); } }

/* Explicit :hover keeps the link static — wollam.css's global a:hover (§2)
   would otherwise turn this navy + underlined on hover, which neither
   field form's original design did. Same inheritance gap the 404 and
   legal-page conversions hit. */
.page-footer { text-align: center; padding: 8px var(--space-4) var(--space-5); }
.page-footer a { font-size: var(--t-compact); color: var(--text-secondary); text-decoration: none; }
.page-footer a:hover { color: var(--text-secondary); text-decoration: none; }

/* --- 18. Icon button + toggle switch ------------------------------------ */
/* Graduated out of projects.html and users.html, which defined both
   identically except two reconciled differences: an explicit 16px icon size
   (present in users.html's pre-conversion original, not in projects.html's
   page-local version — needed once a row shows two icons, edit + delete,
   instead of one) and the toggle's disabled state (present in users.html —
   you can't deactivate your own account — unused and so absent on
   projects.html, which never disables its toggle). Destructive-hover and
   role/status colour variants stay page-local; only the shared shape and
   base behaviour graduate here. */
.btn-icon {
  display: inline-flex; align-items: center; justify-content: center;
  width: var(--control-h); height: var(--control-h); padding: 0;
  border-radius: var(--r-control); border: var(--bw) solid var(--border);
  background: var(--bg-surface); color: var(--text-secondary); cursor: pointer;
}
.btn-icon:hover { border-color: var(--border-strong); color: var(--text-primary); }
.btn-icon svg { width: 16px; height: 16px; }

/* ── The two boolean controls ─────────────────────────────────────────
   Which one to use is a rule about BEHAVIOUR, not taste:

     .check-field   a CHECKBOX. The value is part of a form you submit;
                    nothing happens until Save. "Was it corrected?",
                    "Flag for team review", the SMS consent box.
     .toggle-switch a TOGGLE. Flipping it takes effect immediately, with no
                    save step. "Show inactive", a row's Active state.

   Before this the app used them interchangeably: the roster's "Show
   inactive" was a toggle while tag settings' identical "Show inactive tags"
   was a bare native checkbox, and every real form checkbox was unstyled
   browser default. A control's shape should tell you whether it commits.

   Both carry the 44px touch target and the shared focus ring; the native
   input stays the input (accent-color paints it), so keyboard, screen
   readers and form serialisation all keep working. */
.check-field {
  display: flex; align-items: flex-start; gap: var(--space-3);
  min-height: var(--control-h-touch); cursor: pointer;
  font-size: var(--t-body); color: var(--text-primary);
}
.check-field input[type="checkbox"] {
  width: 20px; height: 20px; margin: 0; flex-shrink: 0;
  accent-color: var(--wollam-navy); cursor: pointer;
  /* Nudged onto the first line's optical centre — a 20px box against a
     14px line sits high without it. */
  margin-top: 1px;
}
.check-field input[type="checkbox"]:focus-visible {
  outline: none; box-shadow: var(--focus-ring); border-radius: var(--r-check);
}
.check-field > span { line-height: 1.5; }
/* The label a toggle sits in, so both controls align the same way in a
   filter strip. */
.toggle-field {
  display: inline-flex; align-items: center; gap: var(--space-2);
  min-height: var(--control-h); cursor: pointer; user-select: none;
  font-size: var(--t-compact); color: var(--text-secondary);
}

.toggle-switch {
  position: relative; display: inline-block; width: 38px; height: 22px;
}
.toggle-switch input { opacity: 0; width: 0; height: 0; }
.toggle-slider {
  position: absolute; cursor: pointer; inset: 0;
  background: var(--border-strong); border-radius: var(--r-pill);
  transition: background var(--t-fast);
}
.toggle-slider::before {
  content: ''; position: absolute; height: 16px; width: 16px; left: 3px; bottom: 3px;
  background: var(--bg-surface); border-radius: 50%; transition: transform var(--t-fast);
}
.toggle-switch input:checked + .toggle-slider { background: var(--status-success); }
.toggle-switch input:checked + .toggle-slider::before { transform: translateX(16px); }
.toggle-switch input:disabled + .toggle-slider { opacity: .4; cursor: not-allowed; }
/* The real input is opacity:0, so without this a keyboard user had no
   idea which toggle was focused. */
.toggle-switch input:focus-visible + .toggle-slider { box-shadow: var(--focus-ring); }

/* --- 19. Drawer close button + review photo grid + photo lightbox ------- */
/* Graduated at Phase 3 Task 11 (equipment_inspections.html, the second
   converted consumer) out of observations.html, which defined all three
   identically once tokenized — governance rule 3. Appended as a new
   section rather than renumbering §10/§11 in place, matching how §18 was
   already appended rather than inserted. */

/* .panel-close: both pre-conversion originals were 32x32, transparent,
   r-control radius, text-secondary glyph colour, bg-sunken hover — the only
   difference was font-size (equipment_inspections' raw 20px vs
   observations' already-tokenized --t-subhead, which IS 20px) and the hover
   transition duration (150ms vs --t-fast's 120ms, close enough that no page
   depended on the 30ms gap). */
.panel-close {
  width: 32px; height: 32px; border-radius: var(--r-control); border: none;
  background: transparent; cursor: pointer; color: var(--text-secondary);
  display: flex; align-items: center; justify-content: center;
  font-size: var(--t-subhead); transition: background var(--t-fast);
}
.panel-close:hover { background: var(--bg-sunken); }
/* 32px is under the 44px touch floor, and closing a full-screen drawer is
   the single most-tapped control on a phone. Stated here rather than in
   §14's mobile block because this rule comes later in the file and a media
   query adds no specificity — from there it would be silently reset. */
@media (max-width: 900px) {
  .panel-close { width: var(--control-h-touch); height: var(--control-h-touch); }
}

/* .panel-photos: bare <img> thumbnails (no wrapping <a>, unlike §11's
   read-only .photo-grid) that open an in-page lightbox on click — see
   observations.html's original comment on this distinction, still true
   here. Base grid + the img treatment (aspect-ratio, radius, border,
   zoom-in cursor, hover brighten) were identical in both pre-conversion
   originals; only grid-template-columns differed (observations' responsive
   auto-fill vs equipment_inspections' fixed 4-up).

   The column count is now shared too, at the fixed 4-up the equipment
   drawer spec asked for ("a grid, not a strip, so all five are visible
   without scrolling" — docs/design/2026-07-29-equipment-inspections-ui.md).

   This also repairs a regression: when this rule graduated, the comment
   above said the count "stays page-local on each page's own .panel-photos
   rule" — but only observations.html ever wrote one. equipment_inspections
   .html was left with `display: grid` and no columns, so its drawer had
   been rendering ONE photo per row at full width, turning five photos into
   a long scroll. Neither page carries a page-local count now. */
.panel-photos { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--space-2); }
.panel-photos img {
  width: 100%; height: 100%; aspect-ratio: 1; object-fit: cover; display: block;
  border-radius: var(--r-control); border: var(--bw) solid var(--border); cursor: zoom-in;
  transition: filter var(--t-fast);
}
.panel-photos img:hover { filter: brightness(1.06); }

/* .lightbox*: bespoke full-viewport photo preview, nothing else in the
   system to reuse. rgba()/alpha colours are banned in a converted template
   (governance rule 4), so the translucent-black backdrop and translucent-
   white buttons are rebuilt without alpha — see observations.html's
   original comment for the full reasoning (solid var(--text-primary) for
   the backdrop; opacity-not-alpha ::before circles for the two buttons,
   the same technique as .btn.loading's spinner). Both pre-conversion
   originals matched exactly once tokenized, down to the 640px breakpoint;
   the only numeric gap was the nav-arrow glyph (equipment_inspections' raw
   30px vs observations' already-rounded --t-title/26px, the same glyph
   size .lightbox-close uses) — reconciled to 26px on both. */
.lightbox {
  position: fixed; inset: 0; z-index: 300;
  background: var(--text-primary);
  display: none; align-items: center; justify-content: center;
}
.lightbox.open { display: flex; }
.lightbox-img {
  max-width: 92vw; max-height: 88vh; object-fit: contain;
  border-radius: var(--r-control); box-shadow: var(--shadow-raised);
}
.lightbox-close, .lightbox-nav {
  position: relative; z-index: 0;
  color: var(--text-on-navy); border: none;
  line-height: 1; cursor: pointer;
  display: flex; align-items: center; justify-content: center;
  background: transparent;
}
.lightbox-close::before, .lightbox-nav::before {
  content: ''; position: absolute; inset: 0; z-index: -1; border-radius: 50%;
  background: var(--text-on-navy); opacity: .12; transition: opacity var(--t-fast);
}
.lightbox-close:hover::before, .lightbox-nav:hover::before { opacity: .28; }
.lightbox-close {
  position: absolute; top: 16px; right: 20px;
  width: 44px; height: 44px; border-radius: 50%;
  font-size: var(--t-title);
}
.lightbox-nav {
  position: absolute; top: 50%; transform: translateY(-50%);
  width: 52px; height: 52px; border-radius: 50%;
  font-size: var(--t-title);
}
.lightbox-prev { left: 20px; } .lightbox-next { right: 20px; }
.lightbox-counter {
  position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%);
  color: var(--text-on-navy); font-size: var(--t-compact); font-weight: 500;
  background: var(--wollam-navy-hover); padding: 5px 12px; border-radius: var(--r-pill);
}
@media (max-width: 640px) {
  .lightbox-img { max-width: 96vw; max-height: 82vh; }
  .lightbox-nav { width: 44px; height: 44px; font-size: var(--t-title); }
  .lightbox-prev { left: 8px; } .lightbox-next { right: 8px; }
}

/* --- 20. Collapsible section (toggle + chevron + body) ------------------ */
/* A button/body pair whose open state persists to localStorage — the JS
   (toggleSection/restoreSections) stays page-local per page since the
   section ids and localStorage keys differ by page; only the shape and
   animation graduate here. Graduated out of communications.html and
   dashboard.html, the second converted consumer (governance rule 3) —
   communications.html's version was already anticipating this exact move
   (see its own pre-graduation comment, now removed). The two pre-conversion
   definitions differed only in whether the toggle carried its own
   background/border/shadow/margin (dashboard.html's original standalone
   button, used directly in the page flow) or sat flush inside a flex header
   row next to a sibling action button (communications.html's, transparent
   background, no border, flex:1 so it shares the row). Reconciled to the
   transparent/flex:1 shape — the more general case, since a page that wants
   the standalone bordered look (dashboard.html) gets it for free by simply
   not nesting the toggle inside another flex row, while the flex:1 rule is a
   no-op with no such row. dashboard.html accordingly also needed to add back
   its own border/background/shadow/margin-bottom on `.collapsible-toggle` as
   a page-local addition — see its page_css comment. max-height also grew
   from communications.html's 2000px cap (dashboard.html's original) to
   4000px (communications.html's original, the larger of the two) so neither
   page's tallest open section gets clipped mid-transition. */
.collapsible-toggle {
  display: flex; align-items: center; gap: 10px;
  padding: var(--space-2) var(--space-3);
  background: transparent; border: none;
  cursor: pointer;
  font-family: inherit; font-size: var(--t-body); font-weight: 600;
  color: var(--text-primary); transition: background var(--t-fast);
  flex: 1; min-width: 0;
  border-radius: var(--r-control);
}
.collapsible-toggle:hover { background: var(--bg-sunken); }
.collapsible-toggle .chevron {
  width: 18px; height: 18px; transition: transform 250ms ease;
  flex-shrink: 0; color: var(--wollam-navy);
}
.collapsible-toggle.collapsed .chevron { transform: rotate(-90deg); }
.collapsible-toggle .section-summary {
  margin-left: auto; font-size: var(--t-micro); font-weight: 500;
  color: var(--text-secondary);
}
.collapsible-body {
  overflow: hidden; transition: max-height 300ms ease, opacity 200ms ease;
  max-height: 4000px; opacity: 1;
}
.collapsible-body.collapsed {
  max-height: 0; opacity: 0; margin-bottom: 0;
}

/* --- 21. Segmented control (track + floating active indicator) --------- */
/* A genuinely different "pick one" idiom from §6's .filter-pill: pills are
   separate rounded buttons with a gap and a NAVY-FILL active state; a
   segmented control is one bordered track with adjoined options and a
   floating, ELEVATED (white + shadow) active indicator — the macOS/iOS
   segmented-control idiom, not a filter idiom. Graduated out of
   dashboard.html's `.recorders-timeframe`/`.rtf-btn` (the timeframe
   switcher above the recorders leaderboard) and banner_editor.html's
   `.preview-tabs`/`.preview-tab` (the Sheet/Email/Text live-preview
   switcher), the cross-page consistency audit's second consolidation
   target — both were independently-invented bespoke button components (own
   typography, own transitions, own disabled state) rather than a shared
   primitive, unlike observations.html's `.view-toggle`, which is not
   graduated here: it composes the existing `.btn`/`.btn-secondary`
   directly (adjoined via border-radius/negative-margin, not a bespoke
   button class) and its own page_css already documents that choice — see
   its comment. Reconciled to `.recorders-timeframe`/`.rtf-btn`'s shape
   (grey track, floating white+shadow active pill): banner_editor.html's
   `.preview-tab` used a navy-fill active state instead (white track, no
   track background) — a real, deliberate visual change on that one
   consumer, made because a navy-fill "selected" state is exactly
   `.filter-pill.active`'s language, and a segmented control needs to read
   as a different control family from a filter, not a differently-shaped
   copy of one. `:disabled` is new here too — `.rtf-btn` never needed it,
   `.preview-tab` did (Email/Text are permanently "Coming soon"). */
.segment {
  display: inline-flex; align-items: center; gap: 2px; padding: 3px;
  background: var(--bg-sunken); border: var(--bw) solid var(--border);
  border-radius: var(--r-control);
}
.segment-btn {
  appearance: none; border: 0; background: transparent;
  padding: var(--space-1) var(--space-3); border-radius: var(--r-control);
  font-family: inherit; font-size: var(--t-caption); font-weight: 600;
  color: var(--text-secondary); cursor: pointer;
  transition: background var(--t-fast), color var(--t-fast), box-shadow var(--t-fast);
}
.segment-btn:hover:not(.active) { color: var(--text-primary); }
.segment-btn.active { background: var(--bg-surface); color: var(--text-primary); box-shadow: var(--shadow-card); }
.segment-btn:disabled { opacity: .5; cursor: not-allowed; }

/* --- 22. Equipment inspection form -------------------------------------- */
/* The unit type-ahead, the inline field errors and the inspection success
   card — everything the equipment inspection FORM needs, as opposed to the
   page that hosts it.

   Graduated out of equipment_inspect.html because that form is now a shared
   partial (templates/_equipment_inspection_form.html) included by two
   templates: the standalone /equipment-inspection page and the field
   landing (observe.html), where it sits beside the observation form. That
   is governance rule 3's second consumer, arriving as an include rather
   than as a copy.

   An include is the one case rule 5 cannot see. The checker reads each
   template on its own, so markup pulled in by {% include %} is never
   attributed to the host — a class defined only in equipment_inspect.html's
   page <style> block would resolve on that page, render as unstyled bare
   text on the landing, and pass the check on both. Everything the partial's
   markup names therefore lives here, not in either host.

   Three rules changed shape on the way in, all for the same reason — an id
   selector cannot serve a document that holds the form twice over, or holds
   it beside a form that had the same id first:
     #equipment-code       -> .eq-code-input
     #equipment-code.is-*  -> .eq-code-input.is-*
     #err-form.visible     -> .field-error.at-form-level.visible
   The sticky-bottom submit did NOT come with them: it stays page-local to
   equipment_inspect.html. On the landing the form sits inside a
   .collapsible-body, whose `overflow: hidden` makes it the sticky
   containing block — the button would dock to the bottom of the collapsed
   section instead of the viewport. Same trap §7 documents for .table-frame
   and sticky table headers. */
.eq-field { position: relative; }
.eq-code-input { font-variant-numeric: tabular-nums; }
.eq-code-input.is-match { border-color: var(--status-success); }
.eq-code-input.is-nomatch { border-color: var(--status-warning); }

.eq-suggestions {
  display: none; margin-top: var(--space-2); border: var(--bw) solid var(--border);
  border-radius: var(--r-control); background: var(--bg-surface);
  box-shadow: var(--shadow-raised); max-height: 280px; overflow-y: auto;
}
.eq-suggestions.visible { display: block; }
.eq-suggestion {
  display: flex; align-items: center; gap: 10px; width: 100%;
  height: 48px; padding: 0 var(--space-4); border: 0; border-top: var(--bw) solid var(--bg-sunken);
  background: none; text-align: left; cursor: pointer; font-family: inherit;
}
.eq-suggestion:first-child { border-top: 0; }
.eq-suggestion.active, .eq-suggestion:hover { background: var(--bg-sunken); }
.eq-suggestion .eq-code {
  font-size: var(--t-body); font-weight: 600; font-variant-numeric: tabular-nums;
  color: var(--text-primary); flex-shrink: 0;
}
.eq-suggestion .eq-code b { font-weight: 700; }
.eq-suggestion .eq-dash { color: var(--border-strong); flex-shrink: 0; }
.eq-suggestion .eq-desc {
  font-size: var(--t-body); color: var(--text-secondary);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

.eq-status {
  display: none; margin-top: var(--space-2); border-radius: var(--r-control);
  padding: 9px var(--space-3); font-size: var(--t-body); line-height: 1.5;
}
.eq-status.match {
  display: flex; align-items: center; gap: var(--space-2); min-height: 40px;
  background: var(--status-success-tint); color: var(--status-success-text);
}
.eq-status.nomatch {
  display: block; background: var(--status-warning-tint); color: var(--status-warning-text);
}

.field-error { display: none; font-size: var(--t-compact); color: var(--status-danger); }
.field-error.visible { display: block; }
.field-error.at-form-level.visible { margin-bottom: var(--space-3); }

.eqi-submit-btn { width: 100%; margin-top: var(--space-1); }

/* Success, as a card. observe.html's success screen is bare (no card
   wrapper) and keeps its own vertical rhythm page-locally. */
.success-card { padding: var(--space-7) var(--space-6); }
.success-check {
  width: 48px; height: 48px; border-radius: var(--r-pill); margin: 0 auto var(--space-4);
  background: var(--status-success-tint); color: var(--status-success);
  font-size: var(--t-title); font-weight: 700;
  display: flex; align-items: center; justify-content: center;
}
.success-title { font-size: var(--t-subhead); font-weight: 600; text-align: center; }
.success-unit {
  font-size: var(--t-body); color: var(--text-secondary);
  margin-top: 6px; text-align: center;
}
.success-actions {
  display: flex; flex-direction: column; gap: var(--space-2); margin-top: 22px;
}
.success-actions .btn { width: 100%; }

/* --- Changelog ---
   - 2026-08-03: [hidden] now actually hides. The UA rule loses to any author
     display declaration, so .btn's inline-flex left `<button class="btn" hidden>`
     visible. Same trap for .card/.panel/.filter-strip/.modal/.tabbar.
   - 2026-08-03: .btn/.btn:hover get text-decoration:none. An <a class="btn"> was
     inheriting the global a:hover underline (0,1,1 beats .btn's 0,1,0) — affected the
     header Logout and incident_detail's Save to PDF / Edit / Create Banner.
/* Governance rule 5: one line per change to this file.
   - Initial adoption from the canonical design system: tokens, reset, and
     the component library (buttons, badges, filter strip, table, card,
     form fields, drawer, toast/modal, mobile block, print).
   - App-shell additions: nav icons, sidebar spacer + footer, mobile menu
     trigger, sidebar overlay.
   - Mobile-menu contract fix: rewired the hamburger/overlay to key off
     body.sidebar-open (the selector mobile.js actually toggles), fixed the
     dead .header padding-left override, added the explicit z-index scale
     in §3, and shifted the open-state button clear of the sidebar logo.
   - This batch: widened scripts/check_tokens.py to scan static/css/*.css
     and the two base templates; added --border-default and --bg-hover as
     legacy aliases for chat.css; killed the phantom .tabbar bottom
     padding on .content; hid .mobile-menu-btn/.sidebar-overlay from
     print; merged the duplicated .sidebar-nav a rule; let the mobile
     sidebar scroll; marked the out-of-scope tab-bar/desktop-required/
     offline-note/sheet-row rules UNADOPTED.
   - Graduated the legal-document layout (§16) out of privacy_policy.html and
     terms.html, which defined it identically: navy .legal-header banner,
     .content-wrapper, .legal-card + its h1/h2/p/ul/li/strong rules,
     .effective-date, .footer-links. Standardized two near-duplicate values
     in the move: .legal-card's box-shadow now uses --shadow-raised (was a
     bespoke two-layer rgba(0,0,0,.05) shadow) and .legal-header's shadow
     color now uses --wollam-navy-tint at .08 alpha (was an inline
     rgba(0,52,126,.15)).
   - Graduated the field-capture form components (§17) out of observe.html
     and equipment_inspect.html, which both defined them (the second
     defined them under the colliding name .photo-grid — renamed to
     .capture-grid to stop shadowing §11's read-only grid): .field-header +
     .header-text h1, .photo-section, .photo-label, .capture-grid,
     .photo-preview + its .remove-btn, .add-photo-btn's shared box
     treatment, .photo-count, .success-screen's display/.visible toggle,
     and .page-footer. Reconciled rather than copied: .photo-preview and
     .add-photo-btn's height are now a fixed 64px on both pages (was
     aspect-ratio-in-a-CSS-grid on observe.html); .header-text h1 is
     font-weight 600 (was 700 on observe.html, matching the app's existing
     .header-title convention); .photo-label dropped its uppercase/
     letter-spacing treatment to match the plain-case field labels used
     everywhere else. Also removed the last three uses of the second blue
     (#2563EB) from observe.html — see the report for the full mapping.
   - Fix: the previous batch's "reconciled" fixed-64px .photo-preview/
     .add-photo-btn shrank thumbnails to 62x62 at a 390px viewport (were
     ~96-145px pre-conversion) — not identifiable as a specific jobsite
     photo. Restored the original's responsive grid (.capture-grid: CSS
     grid, 3 cols / 4 at >=400px viewport) and aspect-ratio:1 tiles on both
     .photo-preview and .add-photo-btn. Also fixed .remove-btn's touch
     target: it measured 26x26, under the 44px mobile floor; added a
     ::before hit-area expansion to var(--control-h-touch) below 900px
     without enlarging the visible circle (which would otherwise cover a
     small thumbnail). Moved the thumbnail's border-radius from
     .photo-preview's overflow:hidden onto the img itself (border-radius:
     inherit) so the remove button's expanded hit area isn't clipped by
     the container. observe.html's .add-photo-btn (the one with the full
     text label) gained a local flex-direction: column to stack icon over
     label now that the button is square again, matching its pre-
     conversion layout — equipment_inspect.html's plain "+" button needed
     no change (its local width: 64px + the shared aspect-ratio: 1 already
     reproduce its prior fixed 64x64 size).
   - Phase 3 Task 0 (shared-layer prerequisites, before the 13-template bulk
     conversion): extended the §3 z-index scale to include chat.css's
     three layers (.chat-fab 44, .chat-backdrop 62, .chat-panel 63 — was
     100/199/200, floating the chat widget above every modal and toast in
     the documented scale). chat.css is shared with the 13 still-unconverted
     templates. This entry originally claimed "renumbering only the
     z-index values is safe for them" — that was WRONG and was corrected
     in a follow-up fix; see below. The check at the time only looked at
     the six unconverted templates' own local z-indexes and each
     template's inline <style> block; it never read static/css/mobile.css,
     which those same 13 templates also load and which stacks mobile
     chrome (.mobile-menu-btn 70, .sidebar-overlay 75, .sidebar open state
     80 !important, all at <=900px) on top of the new 62/63. On mobile,
     where mobile.css makes .chat-panel full viewport width, the 44x44
     hamburger button then painted over an open chat panel and stayed
     tappable, and tapping it slid the nav sidebar over the chat — a
     regression on all six unconverted pages carrying the widget (upload,
     communications, dashboard, incidents, observations,
     equipment_inspections). Checked every local z-index in those six
     templates: the ones below the new 44-63 range (drawer-height elements
     at 40-60) are unaffected — chat was already above them and still is.
     The ones above it (page-local toasts at 100/200, and
     observations.html/equipment_inspections.html's slide-out
     triage/thumb-through .panel-backdrop/.panel at 100/101, and
     lightboxes/confirm modals at 300/400) used to sit *under* the old
     chat-panel z-index of 200 — i.e. opening chat while one of those was
     open used to bury it under the chat panel. They now correctly sit
     above the new, lower chat z-index instead, which is the documented
     scale's intent (chat is a secondary utility panel, not the page's own
     modal/toast/drawer) and fixes rather than regresses that stacking.
     That part held up; the mobile.css chrome conflict above did not.
   - Follow-up fix to the entry directly above: raised .chat-backdrop/
     .chat-panel from 62/63 to 82/83 (transitional — see the comment
     above §3 and the one at the top of chat.css) so they clear
     mobile.css's .sidebar at 80 !important on the 13 unconverted
     templates. .chat-fab stays at 44: mobile.css only repositions/resizes
     it at <=900px, it never raises anything else's z-index near its
     bottom-right corner, so it was never at risk. Drops back to 62/63
     once Phase 4 deletes mobile.css.
     Renamed §16's
     .legal-header/-left/-logo/-title to .standalone-header/-left/-logo/
     -title in wollam.css and in its three consumers (login.html,
     privacy_policy.html, terms.html) — the section is headed "legal /
     standalone document pages" but login.html, which is not a legal
     document, was using the "legal" name for the one component it shares
     (the banner). .legal-card and the other §16 prose classes keep their
     name; only privacy_policy.html and terms.html use those.
   - banner_view.html's .banner-sheet/.bs-* rules (the published-banner
     print/share artifact) moved out of the template entirely into a new
     static/css/banner-sheet.css, linked from banner_view.html's page_css
     block. They had been tokenized in place during that conversion, which
     silently shrank the printed banner headline 28% (.bs-headline 36px ->
     --t-title 26px) and .bs-summary 17px -> --t-lead 16px — a real
     regression on a printed field flyer. Restored both to their original
     raw px values byte-for-byte (recovered from git 6b80a48) in the new
     file; every other rule in the block still resolves against wollam.css
     tokens as before. This is not a raw-value violation: the governance
     rule 4 raw-value check (scripts/check_tokens.py) only scans page
     templates, never static/css/*.css, and the spec already classified
     this block as "leave alone — extract to one shared file, do not
     redesign" (spec §11, bucket D). banner-sheet.css is that one shared
     file; banner_editor.html will link it too once it converts (Task 12)
     — it still inlines its own copy today and was not touched here.
   - Phase 3 Task 2 (users.html): graduated §18 (.btn-icon, .toggle-switch/
     .toggle-slider) out of projects.html and users.html, the second
     converted consumer — governance rule 3. Reconciled the two pre-
     conversion definitions rather than picking one: added an explicit 16px
     .btn-icon svg size (users.html needed it for a two-icon row; projects.html
     had relied on flex-shrink) and the toggle's :disabled state (users.html
     disables it on your own row; projects.html never disables its toggle).
     Deleted both rule blocks from projects.html's page_css.
   - Phase 3 Task 3 (incident_detail.html): .modal-text had no white-space
     rule, so a confirm/prompt message containing "\n\n" (e.g. users.html's
     delete-user confirmation) rendered as one run-on line instead of two
     paragraphs. Added white-space: pre-line, which respects \n without
     collapsing the runs of spaces already used for layout elsewhere in the
     shared dialogs. Shared-layer fix — affects every page using
     askConfirm/askPrompt, not just incident_detail.html.
   - Phase 3 Task 4 (upload.html): graduated .breadcrumb (§3) out of
     banner_view.html and incident_detail.html, the second converted
     consumer — governance rule 3 (deferred once already at Task 3, whose
     report flagged it as a concern). The two pre-conversion definitions
     differed by one property: incident_detail.html added
     margin-bottom: var(--space-2), banner_view.html did not (its
     breadcrumb sits inside .bv-toprow, a flex row where the taller actions
     column already sets the row's height, so the extra margin is
     invisible there). Reconciled to the version WITH margin-bottom, since
     it is required standalone (incident_detail.html) and harmless inside a
     flex row (banner_view.html). Deleted both page-local rule blocks.
   - Phase 3 chat-widget fixes (pre-emptive, before Task 5 converts a second
     chat page): converted pages (upload.html) were silently losing the
     chat widget's mobile-width/FAB-position/safe-area treatment and its
     print hide, all of which lived only in static/css/mobile.css (not
     loaded by converted pages) — moved into static/css/chat.css's own new
     <=900px and print blocks so both converted and unconverted pages get
     them from one place. Also restored chat.css's .chat-backdrop/
     .chat-panel to the documented 62/63 (updated the §3 scale above
     accordingly) and moved the 82/83 mobile.css-clearing exception into
     mobile.css itself, scoped to its own <=900px block — the exception
     belongs to that file's chrome, not to the shared file every converted
     page inherits. Deleted upload.html's now-redundant page-local 62/63
     override (added back in the entry above this one) along with its
     explanatory comment. Also added a 16px iOS-zoom-floor rule for
     .chat-input at <=900px in chat.css (raw px, not var(--t-lead) — the
     12 unconverted templates' local :root token sets don't define it) and
     folded chat.css/marked/chat.js, previously three hand-copied lines
     per page, into _chat_widget.html so a converted chat page needs one
     include.
   - .empty (§12) set no color, unlike the .empty-state class it replaced
     during conversion (which used --text-tertiary) — every converted
     page's "Loading...", empty-list, and error text was rendering
     near-black instead of muted. Added color: var(--text-secondary) to
     match .empty p's existing color rather than reintroducing a third,
     inconsistent shade.
   - Phase 3 mobile-table fix: .table-frame's overflow: hidden clipped a
     table wider than the frame instead of making it reachable — on
     /users and /projects at a 390px viewport the Role column cut off
     mid-word and the Active toggle + edit/delete buttons were entirely
     unreachable (clipped, not scrollable). Changed the base rule (§7) to
     overflow-x: auto; overflow-y: hidden — the frame now scrolls
     horizontally and still clips to border-radius on both axes, so the
     rounded-corner intent survives. projects.html/users.html's page-local
     override (only overflow-y: auto + max-height, for the sticky header)
     is untouched and layers on top since page_css loads after this file:
     the frame ends up scrolling both axes, and position:sticky keeps
     working because it only cares about the container's vertical scroll —
     scrolling it horizontally doesn't detach the sticky thead. Also wrapped
     upload.html's .history-table (previously not wrapped in anything) in
     .table-frame so it stops making the whole page scroll sideways
     (document.scrollWidth 571 against a 390 viewport, pre-fix) and matches
     every other converted table.
   - Phase 3 Task 8 (equipment.html): graduated .table-pagination (§7) out of
     incidents.html and equipment.html, the second converted consumer —
     governance rule 3. Both pre-conversion definitions were already
     identical once tokenized (incidents.html tokenized it in Task 7); no
     reconciliation needed. Deleted the page-local copy from incidents.html's
     page_css. Also used the pre-existing .badge/.badge-neutral (§5) for
     equipment.html's "Inactive" status pill instead of a bespoke page-local
     pill — its tint+text pairing measures 6.92:1 against the page's original
     literal --bg-hover + #64748B, which measured 4.35:1, under 4.5:1 AA.
   - Phase 3 Task 9 (incident_form.html): graduated .detail-grid/.detail-grid-2
     (§9) out of incident_detail.html and incident_form.html, the second
     converted consumer — governance rule 3. incident_form's pre-conversion
     .form-row/.form-row-2 additionally carried margin-bottom: 16px; dropped
     rather than reconciled in, since every .field grid item already carries
     its own margin-bottom: var(--space-4), making the row's own bottom
     margin redundant double-spacing. Deleted the page-local copy from
     incident_detail.html's page_css.
   - Phase 3 Task 10 (observations.html): graduated the sticky/scrollable
     data table (§7, new `.table-frame.has-sticky-header`) out of
     projects.html and users.html into wollam.css, and killed the
     unexplained `70px` both carried — replaced with a page-measured
     `--table-frame-top` custom property (see the rule's own comment for
     the full rationale). Deleted the page-local `.table-frame`/`.data-
     table thead th` override from projects.html, users.html and
     incidents.html's `#panel-actions` block; all three now add
     `has-sticky-header` to the relevant `.table-frame` div and set
     `--table-frame-top` locally instead.
   - Phase 3 Task 11 (equipment_inspections.html): added new §19 —
     graduated `.panel-close`, `.panel-photos`/`.panel-photos img`, and the
     full `.lightbox*` block out of observations.html, the second converted
     consumer to need all three identically. Deleted the three page-local
     copies from observations.html's page_css (it keeps only its own
     `.panel-photos` grid-template-columns line). Also gave
     equipment_inspections.html's review table `has-sticky-header` (§7,
     `--table-frame-top: 187px`, measured the same way as observations.html
     — see that page's page_css comment for the method) — its pre-
     conversion original scrolled the table body without a fixed header;
     `has-sticky-header`'s sticky header is a deliberate, in-scope chrome
     improvement (matching every other converted review table), not a
     workflow change.
   - Phase 3 Task 12 (banner_editor.html): graduated `.detail-header-actions`
     (§3) and `.detail-section`/`.detail-section-title` (§8) out of
     incident_detail.html and banner_editor.html, the second converted
     consumer — governance rule 3. `.detail-header-actions` gained
     `align-items: flex-start` (banner_editor.html's addition, harmless on
     incident_detail.html's uniform-height button row). `.detail-section`'s
     card padding/shadow normalized to the shared `.card` (16px/
     --shadow-card) instead of banner_editor.html's pre-conversion 20px/
     --shadow-sm — the same normalization incident_detail.html's own
     conversion already made at Task 3. Deleted both page-local copies from
     incident_detail.html's page_css.
   - Phase 3 Task 13 (dashboard.html): graduated `.filter-field`/
     `.filter-field-label` (§6) out of incidents.html and dashboard.html, the
     second converted consumer — governance rule 3. Deleted the page-local
     copy from incidents.html's page_css. Also added new §20 —
     `.collapsible-toggle`/`.collapsible-body`/`.chevron`/`.section-summary`
     — graduated out of communications.html and dashboard.html, the second
     converted consumer that communications.html's own pre-conversion
     comment had already flagged as pending. Reconciled the two definitions
     to the flex:1/transparent-background shape (communications.html's,
     nested inside a header row next to a sibling button) rather than
     dashboard.html's standalone bordered button — dashboard.html gets the
     bordered look back via its own page-local addition since nothing forces
     the shared rule's background/border, and the flex:1 rule is a no-op
     without a wrapping row. Deleted both page-local copies. The toggle/
     restore JS and each page's own localStorage keys are unaffected — those
     stay page-local and were not touched.
   - Cross-page consistency audit fixes (three Critical findings from
     comparing all 21 converted pages to each other, not just each against
     its own pre-conversion original):
     (1) Graduated `.card-grid`'s three shared properties (grid-template-
     columns/gap/the 600px collapse) and the full `.obs-card` base rule + its
     hover into §8, the second/third converted consumers being
     equipment_inspections.html and observations.html. equipment_inspections.
     html's page_css had never defined the base `.obs-card` at all — only its
     `.card-flagged`/`.card-unverified` colour overrides — because
     `.obs-card`'s only other definition lived in observations.html's own
     page-scoped `<style>` block, which does not apply to a different
     template. Its mobile card list rendered with no background/border/
     radius/padding/gap, and the flagged/unrecognized colour coding was
     invisible (border-left-color with no border-left-width/style renders
     nothing) — confirmed in a browser at 390px. Deleted both page-local
     copies (`.card-grid`'s grid-template-columns/gap/display and the base
     `.obs-card`/`.obs-card:hover` rule) from observations.html's and
     equipment_inspections.html's page_css; each keeps only its own view-
     toggle-vs-breakpoint show/hide mechanism and its own status-colour
     variants.
     (2) Graduated the sortable-header affordance (`.data-table th.sortable`/
     `:hover`/`.sorted`/`.sort-arrow`) into §7, out of observations.html —
     equipment_inspections.html's page_css referenced `.sortable`/`.sort-
     arrow` and drove them from its own JS, but the rule only ever lived in
     observations.html's page-scoped `<style>` block; equipment_inspections.
     html's own comment claimed the affordance came "from the shared
     .data-table now" when `.data-table` defined no such thing — corrected
     that comment to describe the actual graduation. Deleted the page-local
     copy from observations.html's page_css.
     (3) `.data-table`'s base rule baked `cursor: pointer` and a row hover
     into every consumer regardless of whether its rows did anything on
     click. Two pages needed a genuinely read-only table and forked the
     whole component instead (upload.html's `.history-table`, incident_
     detail.html's `.ca-table`), and a third (dashboard.html's recorders
     leaderboard) papered over it with an inline `style="cursor:default"` —
     three different header weights or paddings across the same nominal
     component. Moved `cursor: pointer` and the row hover to a new `.data-
     table.is-clickable tbody tr`/`:hover`, deleted `.history-table` and
     `.ca-table` entirely (both templates now use plain `.data-table`, with
     only their genuinely page-local bits — the filename-truncation rule,
     renamed `.upload-filename`; the Action column's floor width — kept
     local), removed dashboard.html's now-unnecessary inline `style=
     "cursor:default"`, and added `.is-clickable` to the three tables whose
     rows actually navigate on click, checked against each page's own JS:
     observations.html (`<tr onclick="openPanel(...)">`), incidents.html's
     `#incidents-table` (`onclick="window.location=..."`) and `#actions-
     table` (`tr.addEventListener('click', openCaDrawer)`), and equipment_
     inspections.html (`tr.addEventListener('click', openPanelFromRow)`).
     projects.html, users.html and equipment.html's rows carry only inner
     action buttons and do nothing on click, so they were left as plain
     `.data-table` and correctly lose the pointer cursor they used to show
     despite never having navigated.
     Also fixed, found while editing this section: the "Governance rule 5"
     changelog comment opened above (before the Phase 3 Task 0 entry) closed
     early, right after the Task 9 entry — the comment-close marker that was
     meant to end the whole changelog landed mid-way through it. Every entry
     from Task 10 onward (Tasks 10-13) had therefore been sitting outside
     any comment, as literal invalid CSS text at the end of the file (a
     stray, harmless-but-wrong parse the browser's CSS tokenizer silently
     discards looking for an opening brace it never finds). Removed the
     misplaced closer; the block now closes once, at the true end of the
     file.
   - Added `scripts/check_tokens.py` rule 5 (class resolution): a class
     referenced in a converted template's markup or client-side row-
     building JS must resolve to a rule in wollam.css, the template's own
     `page_css`, or a stylesheet it `<link>`s (chat.css/banner-sheet.css).
     This is the rule that would have caught both `.obs-card` and
     `.sortable` above at commit time — see the script's own module
     docstring for the dynamic-class-name allowlist and false-positive
     handling.
   - Cross-page consistency audit, Important finding 1: `.card` shipped
     without `box-shadow`, even though every one of its own consumers
     (observe.html, tag_settings.html, incident_form.html,
     incident_detail.html) needed one — the seven other page-local "card"
     families that never adopted `.card` at all (dashboard.html's
     `.kpi-card`/`.chart-card`, incidents.html's `.kpi-mini`,
     communications.html's `.tbt-card`/`.banners-list`,
     banner_editor.html's `.incident-picker`, login.html's `.login-card`)
     each had to hardcode `box-shadow: var(--shadow-card)` themselves to
     get the elevation `.table-frame` — the same elevation role — already
     ships with. Added `box-shadow: var(--shadow-card)` to `.card` (§8).
     Six of the seven now also carry `class="card"` alongside their own
     class in markup/JS (`.kpi-card`, `.chart-card`, `.kpi-mini`,
     `.tbt-card`, `.banners-list`, `.incident-picker`), the same
     `class="card composer-category-fieldset"` pattern
     `.composer-category-fieldset` already used in banner_editor.html, and
     had their own now-redundant `box-shadow: var(--shadow-card)` line
     deleted — every other property they declare (background/border/
     radius/padding) still wins over `.card`'s where it differs, since a
     template's `page_css` loads after this file at equal selector
     specificity, so no other visual property changed. `.login-card` was
     left untouched: it uses `--shadow-raised`, a deliberately heavier
     elevation for the one full-viewport auth card in the app, not an
     oversight to reconcile.
   - Cross-page consistency audit, Important finding 2: seven independently-
     invented "pick one of these" components (`.filter-pill`, incidents.
     html's `.tab-btn`, incident_form.html's `.toggle-btn`, observations.
     html's `.chip`, dashboard.html's `.rtf-btn`, banner_editor.html's
     `.preview-tab`, observations.html's `.view-toggle .btn`), four of which
     (`.tab-btn`, `.toggle-btn`, `.chip`, `.view-toggle .btn`) had
     byte-identical navy active states and differed only in radius/padding.
     Deleted `.tab-btn` (incidents.html) and `.toggle-btn` (incident_form.
     html) entirely in favour of the shared `.filter-pill` (§6) — both were
     visually indistinguishable single/multi-choice pills, kept page-local
     under their own name for a semantic distinction ("not a data filter")
     the audit judged not worth a second component for. `.chip`
     (observations.html) and `.unit-chip` (equipment_inspections.html) kept
     their own names — each has real behaviour `.filter-pill` doesn't
     (per-tag runtime colour; an inline clear button) — but had their
     `border-radius` corrected from `--r-pill` to `--r-control`: §1 reserves
     `--r-pill` for a status badge, "never on a control," and both are
     clickable controls. Added new §21, `.segment`/`.segment-btn` (track +
     floating elevated active indicator), graduating dashboard.html's
     `.recorders-timeframe`/`.rtf-btn` and banner_editor.html's
     `.preview-tabs`/`.preview-tab` onto one shared component — both were
     bespoke "segmented control" button systems (own typography, own
     transitions) rather than a shared primitive. `.view-toggle` (observ-
     ations.html) was judged individually and left page-local, unlike the
     other two: it composes the existing `.btn`/`.btn-secondary` directly
     (adjoined via border-radius/negative-margin) rather than inventing a
     new button class, and its own page_css already documented that choice
     before this audit. See each touched template's page_css comment for
     the per-component reasoning.
   - Cross-page consistency audit, Important finding 3: the primary action
     had four different homes across the app (a full-size .btn-primary in
     .page-head on projects/users/board/incidents; inside .filter-strip on
     equipment; a demoted .btn-secondary.btn-sm in .page-head on
     observations, "+ New Observation" — the page's whole purpose — reading
     as a minor secondary action; a .btn-primary.btn-sm inside a section
     header on communications), and tag_settings.html shipped two
     .btn-primary on one view against §4's "one primary button per view."
     No wollam.css rule changed for this fix (it's a markup/placement
     consolidation, not a new component) — observations.html's "+ New
     Observation" is now a full-size .btn-primary; equipment.html's
     "+ Add unit" and communications.html's "+ New Banner" (admin-only)
     both moved into .page-head, full-size; tag_settings.html's Hazard
     Categories "Add" was demoted to .btn-secondary (Action Tags, listed
     first, keeps the primary). See each template's own page_css/markup
     comment for the per-page reasoning.
   - Cross-page consistency audit, Important finding 4: the three search-box
     pages disagreed on both position and width. observations.html/
     equipment.html used the shared `.search-box` at its default
     margin-left:auto/220px; equipment_inspections.html overrode width to
     300px; equipment.html separately overrode `.search-box` to
     margin-left:0/240px AND gave `.filter-strip .btn-primary` its own
     margin-left:auto — inverting the rule, search pinned left, "+ Add
     unit" pushed right, the mirror image of the other two pages (equipment.
     html's two overrides were deleted as part of fix 3, which also moved
     "+ Add unit" into `.page-head`). Standardized `.search-box`'s shared
     width (§6) to 300px — equipment_inspections.html's value, since its
     placeholder text needs the room and 300px reads fine on the two
     shorter-placeholder pages too — and deleted its now-redundant local
     override. Also added `.filter-spacer { flex: 1 }` (§6): the "pills ->
     dropdowns -> search at margin-left:auto" ordering rule this section's
     own heading describes was enforced only by that margin-left:auto on
     `.search-box`, so a strip with no search box had no shared way to
     express "push what comes after this point right" and each invented its
     own — observations.html's `.spacer`, dashboard.html's
     `.filter-reset { margin-left: auto }` baked onto the Clear-filters
     button itself, conflating a control's own styling with strip
     positioning. Both graduated onto the one shared element.
   - Cross-page consistency audit, Important finding 5: only 4 of 9 empty
     states complied with §12 (heading, one sentence, the action), and
     three pages (projects.html, users.html, upload.html's initial state)
     used `.empty` for a LOADING state, not an empty one. Added `.data-
     table td.empty { padding: var(--space-8) var(--space-6) }` — `.empty`
     inside a `<td>` (projects.html/users.html) was losing its own padding
     to `.data-table td` (§7), a higher-specificity class+type selector,
     and rendering with only `.empty`'s `text-align: center` visibly
     applied. Added `.skeleton`/`@keyframes skeleton-shimmer`, graduated out
     of communications.html and dashboard.html — a governance rule 3 miss
     also flagged by finding 6 below, folded in here since fix 5 is what
     needed a shared loading treatment to exist — and used it for every
     "Loading…" placeholder that used to be a bare `.empty` text node:
     projects.html/users.html's initial table rows, upload.html's initial
     history block. Brought observations.html (SVG +
     bare div → h3 + p + a "Clear Filters" button reusing the existing
     `clearFilters()`), projects.html/users.html (both their "No X yet" and
     "Failed to load X" rows → h3 + p + a real button wired to the page's
     own `openAddModal()`/`load*()` functions), upload.html (a bare
     sentence, both the empty and failed states → h3 + p + button wired to
     the existing file-input/`loadHistory()`), and dashboard.html's
     recorders-leaderboard empty state (h3 only, plus an inline
     `style="padding:…"` fighting the component → h3 + p + a new
     `resetRecordersFilters()` action, and the inline style deleted) up to
     §12. No copy invents a capability the page didn't already have — every
     action button calls a function the page already defined.
   - Cross-page consistency audit, Important finding 6 (governance rule 3
     misses, 2+ identical consumers): graduated `.header-count` (§3) out of
     equipment.html and equipment_inspections.html, byte-identical.
     Graduated `.data-table .align-right`/`.align-center` (§7) out of
     dashboard.html; incidents.html's `.cost-col { text-align: right }` was
     the same job under a second name and is gone, replaced with the shared
     `.align-right`. Changed `--drawer-w` from 560px to 520px: every one of
     the three `.panel` consumers already overrode the old 560px default —
     observations.html/equipment_inspections.html to byte-identical 520px
     overrides, incidents.html's `#ca-drawer` to a deliberately narrower
     420px (a third width, left as its own override — see that page's
     page_css comment) — so nothing anywhere actually rendered at 560px,
     and moving the default to 520px let both identical overrides be
     deleted instead of graduating a third, unnecessary modifier class.
     `.skeleton` (the fourth item in this finding) was already graduated in
     the fix 5 entry above, which needed it first.
   - Fix 1 follow-up (found in browser verification, not by the checker):
     combining `class="card X"` gave `.chart-card` (dashboard.html) and
     `.banners-list` (communications.html) `.card`'s `padding: var(--space-4)`
     for the first time — neither had ever declared its own padding,
     because each manages spacing entirely through its children
     (`.chart-card-header`/`-body`, each with their own padding; `.banner-
     row`, with its own padding and full-width dividers) and relies on
     `overflow: hidden` to clip to the frame. The inherited 16px container
     padding stacked on top of both: double-padding every chart header/
     body and putting an unwanted gutter around the recorders leaderboard's
     intentionally-flush table, and on communications.html at 390px,
     shrinking each `.banner-row`'s available width enough that the
     headline visibly collided with the status badge. Added `padding: 0`
     back to both page-local rules — `.card`'s shadow/background/border/
     radius still apply, only the padding is opted out of, since these two
     already manage their own.
   - Final-gaps pass: gave `.panel`/`.panel-backdrop` (§10) a real default-
     hidden state and `.open` contract — previously neither defined any
     show/hide behaviour, so all three consumers reinvented it, and the
     exact defect this produced (a drawer visible on page load) shipped
     twice during conversion, caught only by a live screenshot each time.
     Graduated the opacity/visibility + transform/transition pattern
     observations.html and equipment_inspections.html had already,
     independently, converged on byte-for-byte as page-local overrides;
     deleted both. incidents.html's `#ca-drawer` used a different
     mechanism (inline `style="display:none"` + JS `style.display`
     toggling) — converted its markup and `openCaDrawer`/`closeCaDrawer`
     to the same `.open` class convention so there is one show/hide
     mechanism, not two. Re-verified all three drawers in a real browser
     after the change: hidden on load, open on trigger, close on their
     close control/backdrop click/Escape where each page already
     supported it, and equipment_inspections.html's thumb-through
     keyboard nav (arrows/J/K/F) still functions with the drawer open.
   - Deleted `.dialog`/`.dialog-actions` (dead: the shared `askConfirm`/
     `askPrompt` in `static/js/ui.js` use `.modal-sm`, not this class —
     confirmed nothing in markup, JS, or another stylesheet referenced
     either name). Two confirm patterns already coexisting is exactly what
     `docs/design/design-system-gaps.md` C1 warned would invite a third if
     left undefined; removing the unused one instead. Repointed the
     `.btn-primary` comment's "Destructive actions confirm — see .dialog"
     cross-reference to `.modal-sm`, the pattern that's actually used.
   - Phase 4: deleted `static/css/mobile.css` — all 24 templates now extend
     the base shell and none link it (confirmed by grep). With it gone,
     the transitional 82/83 `.chat-backdrop`/`.chat-panel` z-index
     override it carried (clearing its own mobile chrome, which stacked up
     to z-index 80 at <=900px) is also gone; chat.css's base rule was
     already 62/63 — the documented, permanent §3 scale value — so no
     z-index actually changes on any page, only the now-obsolete exception
     is removed. Reworded the §3 z-index comment and chat.css's matching
     comment block to describe this as done rather than pending, and fixed
     three other comments left claiming stale template-conversion counts
     ("the 20 unconverted templates" x2 on the `--bg-hover`/
     `--border-default` legacy aliases, "the 12 templates not yet
     converted" in §3, "the 13 unconverted templates" on the mobile
     `.mobile-menu-btn` open-state comment, plus the equivalent "12
     unconverted templates" / "unconverted chat pages" wording in
     chat.css) — all now read 0, since every template converted. Verified
     in a real browser after the deletion: chat opens/closes correctly on
     multiple chat-enabled pages and the mobile hamburger still opens the
     sidebar at a 390px viewport — both behaviours mobile.css used to
     carry, now fully sourced from wollam.css's own mobile block (§3) and
     chat.css. `mobile.js` is unrelated (drives `body.sidebar-open`, still
     linked from `_base.html`) and was not touched.
   - Pre-live contrast fix: split `--text-tertiary`'s role (owner's explicit
     decision — rejected darkening the token, which would have collapsed
     tertiary and secondary to near-identical greys). `--text-tertiary`
     (2.56:1 on white) now stays for decorative use only; every
     informational use moved to `--text-secondary` (7.58:1). In this file:
     moved `.eyebrow`, `.breadcrumb`, `.filter-field-label`, `.card-date`,
     `.field .hint`, `.panel-section-label`, `.panel-field-label`,
     `.photo-more`, `.tabbar a` (mobile nav labels — read to navigate, same
     as any other nav text), `.effective-date`, `.footer-links` (+ its
     `:hover`), `.photo-label span`, `.photo-count`, `.page-footer a` (+ its
     `:hover`), and `.collapsible-toggle .section-summary` to
     `--text-secondary`. Left `.input::placeholder` on `--text-tertiary` —
     the one genuinely decorative use of the token in this file. See
     `.superpowers/sdd/contrast-and-ui-fixes-report.md` for the full
     84-site audit across this file and every template.
   - 2026-08-12: `.offline-note` is no longer UNADOPTED — observe.html and
     equipment_inspect.html both render it, and both now queue a failed POST
     to IndexedDB instead of showing a network error (design system §4,
     "never lose the entry, never blame the user"). Moved the rule OUT of the
     `<=900px` block into §17: it had been mobile-only since v1.0, which
     nobody noticed while it had no consumers, but a laptop that loses wifi
     needs the same note and was getting unstyled bare text. No value
     changed in the move.
   - 2026-08-12, geometry pass: tokenized the remaining raw border-radius and
     box-shadow values across wollam.css, chat.css and the template page_css
     blocks (~35 substitutions). Added `--r-indicator: 3px` for the active-nav
     bar's compound radius — indicator geometry, composed as
     `0 var(--r-indicator) var(--r-indicator) 0`, deliberately NOT mapped onto
     --r-control. chat.css's three raw shadows collapsed onto the neutral
     scale: `.chat-panel` was --shadow-drawer with drifted alpha (.12 vs .15),
     and `.chat-send`/`.chat-fab` were brand-tinted elevation the system does
     not define (owner's call: collapse rather than mint --shadow-navy-*).
     `.standalone-header`'s `0 2px 8px var(--wollam-navy-tint)` -> --shadow-card.
     Pixel shifts, all owner-approved: 6px -> --r-control (+2) on .skeleton,
     .lightbox-img, .segment-btn and four template rules; 2px -> --r-check (+2)
     on two; 16px -> --r-container (-4) on .legal-card and .login-card; 10px
     split by ROLE rather than arithmetic (it is equidistant) — cards and
     panels to --r-container (+2), the .card-icon square and .badge-you chip
     to --r-control (-2). scripts/check_tokens.py now rejects non-token radius
     and shadow in both templates and stylesheets, honoring an allow-list
     (50%, inherit, 0, 9999px, none), skipping @media print, and exempting
     banner-sheet.css. Left raw and reported as unmapped: .chat-fab's 24px and
     the chat close button's 18px (icon-glyph metrics, between --t-subhead and
     --t-title), and .chat-input's 16px, which is an iOS zoom FLOOR — a token
     would silently drop it if the scale ever moved.
   - 2026-08-19, review-drawer unification: the observations and equipment-
     inspections drawers read as two different products one nav item apart,
     so the equipment drawer's chrome graduated here as the shared one
     (governance rule 3, observations being the second consumer):
     `.panel-navbar`, `.panel-nav-btn`, `.panel-position`,
     `.panel-flag-toggle` (+ `.active`), `.panel-title-row`, `.panel-title`,
     `.panel-subtitle` and `.panel-desc`. The equipment page keeps its
     element ids for its JS; only styling moved to classes.
     `.panel-header` has NO consumer left — both drawers now open with the
     nav bar — and is kept, documented, as the plain one-title-one-close
     variant rather than deleted.
     `.panel-photos` gained `grid-template-columns: repeat(4, 1fr)`. This
     repairs a regression: when that rule graduated, its comment said the
     column count "stays page-local on each page's own .panel-photos rule",
     but only observations.html ever wrote one, so the equipment drawer had
     been rendering ONE photo per row at full width. Both pages are now the
     4-up the equipment drawer spec asked for, and observations' page-local
     80px auto-fill strip is deleted.
     `.obs-card.selected` added beside the existing `.data-table tbody
     tr.selected`: the drawer can walk between records now, and in card view
     nothing said which card you were on.
     Touch floors: `.panel-flag-toggle` (30px measured at 390px) gets
     `min-height: var(--control-h-touch)` in the <=900px block;
     `.panel-close` (32px) gets its own media query beside its definition in
     §19 instead, because that definition comes LATER in this file and a
     media query adds no specificity — placed in §14 it was silently reset,
     which the measurement caught.
   - 2026-08-19, equipment inspections gets a Findings column: `.card-desc`
     graduated out of observations.html (governance rule 3, equipment_
     inspections.html the second consumer) so the mobile card can carry the
     same condition note the new table column carries. `.col-spacer`/
     `.td-spacer` lost their only consumer in the same change and are kept,
     documented, rather than deleted — see the note beside them. */
