/* ============================================================
   Guidant Power — Component Styles
   Buttons · chips · cards · tables · forms · modals
   Shell chrome · charts · overlays · pagination
   ============================================================ */

/* ---- Buttons ---- */
.btn {
  display: inline-flex; align-items: center; gap: 6px;
  padding: 8px 16px;
  border-radius: var(--radius-md);
  border: none; cursor: pointer;
  font-size: 14px; font-weight: 500;
  line-height: 1;
  transition: background 0.15s, filter 0.15s, border-color 0.15s;
  white-space: nowrap;
  text-decoration: none;
}
.btn-primary   { background: var(--guidant-navy); color: white; }
.btn-primary:hover { background: var(--guidant-navy-dark); text-decoration: none; }
.btn-yellow    { background: var(--guidant-yellow); color: var(--guidant-navy); font-weight: 700; }
.btn-yellow:hover  { filter: brightness(0.94); text-decoration: none; }
.btn-secondary { background: white; color: var(--guidant-navy); border: 1px solid var(--border); }
.btn-secondary:hover { border-color: var(--guidant-navy); text-decoration: none; }
.btn-tertiary  { background: transparent; color: var(--guidant-navy); padding-left: 0; padding-right: 0; }
.btn-destructive { background: var(--color-open); color: white; }
.btn-sm  { padding: 5px 10px; font-size: 12px; }
/* A button that is ONLY an icon — the remediation action on the worklist (2026-08-09). Square, so a
   column of them is a column rather than a ragged edge, and the icon is centred rather than sitting
   where a text baseline would put it. The accessible name lives in aria-label; nothing here renders
   a word. */
.btn-icon {
  display: inline-flex; align-items: center; justify-content: center;
  width: 28px; height: 28px; padding: 0;
}
.btn-lg  { padding: 12px 24px; font-size: 16px; }
.btn:disabled, .btn[disabled] { opacity: 0.4; cursor: not-allowed; pointer-events: none; }

/* ---- Status & severity chips ---- */
.chip {
  display: inline-flex; align-items: center; gap: 4px;
  padding: 3px 8px;
  border-radius: var(--radius-sm);
  font-size: 12px; font-weight: 500;
  white-space: nowrap;
}
/* An upload preview's Action chips. Three looks over seven verbs, because what a reviewer scanning a
   hundred rows needs from the colour is only "does this row add, change nothing, or take something
   away" — the word says which of the seven it is. Tinted like every other chip in the product rather
   than solid: a preview table is not louder than the pages around it. */
.chip-act-write   { background: var(--wash-blue); color: var(--color-new); }
.chip-act-none    { background: var(--wash-grey); color: var(--ink-slate); }
.chip-act-remove  { background: var(--wash-red); color: var(--danger); }

/* A retired site, in the Compliance column of Update Sites. Grey and quiet on purpose: it is not a
   compliance state and not work to do, it is the row saying it has left the programme. Same values
   as .chip-act-none rather than that class itself — the action chips belong to a preview table, and
   sharing a name would tie a permanent row state to a vocabulary of upload verbs. */
.chip-retired     { background: var(--wash-grey); color: var(--ink-slate); }

.chip-new         { background: var(--wash-blue); color: var(--color-new); }
.chip-open        { background: var(--wash-red); color: var(--color-open); }
.chip-in-progress { background: var(--wash-amber); color: var(--ink-amber); }
.chip-complete    { background: var(--wash-green); color: var(--color-complete); }
.chip-deferred    { background: var(--wash-slate); color: var(--color-deferred); }
.chip-declined    { background: var(--wash-grey); color: var(--color-declined); }
/* Severity chips are keyed on the LEVEL, not on a name (view.SeverityClass). Renaming level 4
   from "Serious" to whatever a product line calls it does not touch this file, and a line with its
   own vocabulary still gets the colour its position on the ladder earns. */
.chip-sev-5       { background: var(--wash-red); color: var(--color-sev-5); }
.chip-sev-4       { background: var(--wash-orange); color: var(--color-sev-4); }
.chip-sev-3       { background: var(--wash-amber); color: var(--color-sev-3); }
.chip-sev-2       { background: var(--wash-green); color: var(--color-sev-2); }
.chip-sev-1       { background: var(--wash-grey); color: var(--color-sev-1); }
/* A count that is neither good nor bad — the ingest preview's "N unchanged". It borrowed the old
   .chip-na severity class until 2026-08-04, which meant a styling decision about severity silently
   restyled a number that has nothing to do with severity. */
.chip-neutral     { background: var(--wash-grey); color: var(--ink-slate); }
/* A retracted recommendation. Deliberately muted rather than alarming: the customer only sees this
   on work they already did, and the badge is a note about provenance, not a warning. */
.chip-withdrawn   { background: var(--wash-grey); color: var(--color-declined); text-decoration: line-through; }
.chip-current     { background: var(--wash-mist); color: var(--ink-steel); }
.chip-coming-due  { background: var(--wash-amber); color: var(--ink-amber); }
.chip-scheduled   { background: var(--wash-green); color: var(--color-scheduled); }
.chip-past-due    { background: var(--wash-red); color: var(--color-past-due); }
.chip-delinquent  { background: var(--wash-crimson); color: var(--color-delinquent); }

/* ---- Cards ---- */
.card {
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-sm);
}
.card-header {
  padding: 12px 16px;
  border-bottom: 1px solid var(--border);
  display: flex; align-items: center; justify-content: space-between;
}
.card-title  { font-size: 14px; font-weight: 700; }
.card-body   { padding: 16px; }

/* ---- KPI tiles ---- */
.kpi-tile {
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  padding: 16px;
  cursor: pointer;
  transition: box-shadow 0.15s;
  text-decoration: none;
  display: block;
}
.kpi-tile:hover { box-shadow: var(--shadow-md); text-decoration: none; }
.kpi-label { font-size: 12px; color: var(--text-secondary); margin-bottom: 4px; }
.kpi-value { font-size: 28px; font-weight: 700; color: var(--guidant-navy); line-height: 1; }
.kpi-sub   { font-size: 12px; color: var(--text-secondary); margin-top: 4px; }

/* ---- Data tables ---- */
.table-wrap { overflow-x: auto; }
.data-table { width: 100%; border-collapse: collapse; }
.data-table th {
  text-align: left; padding: 10px 12px;
  font-size: 12px; font-weight: 600; color: var(--text-secondary);
  background: var(--surface-muted);
  border-bottom: 1px solid var(--border);
  white-space: nowrap;
}
/* The ↕ glyph is dimmed until a column is the active sort. */
.data-table th.sortable { cursor: default; }
.data-table th.sortable .sort-ind { opacity: 0.45; }

/* The affordance is JS-owned on TIER A ONLY, and the gate is `data-list` — the marker list.js
   selects on. list.js sets data-sort-ready once it has bound its handlers, and only then does a
   header look clickable, so a click cannot land before anything is listening.
   This used to key off `.sortable` alone, which hid the glyph on the Tier B lists too: their
   headers are server-answered links, nothing ever sets data-sort-ready on those tables, and so
   /reports, /remediations and /equipment rendered a sort indicator that CSS never revealed
   (fixed 2026-08-07). The columns sorted correctly the whole time — the only thing missing was
   the mark that says they can, which is exactly the disparity this pass was opened against. */
.data-table[data-list] th.sortable .sort-ind { display: none; }
.data-table[data-sort-ready] th.sortable { cursor: pointer; user-select: none; }
.data-table[data-sort-ready] th.sortable:hover { color: var(--guidant-navy); }
.data-table[data-sort-ready] th.sortable .sort-ind { display: inline; }
.data-table th.sortable[aria-sort="ascending"] .sort-ind,
.data-table th.sortable[aria-sort="descending"] .sort-ind { opacity: 1; color: var(--guidant-navy); }
.data-table td {
  padding: 10px 12px;
  border-bottom: 1px solid var(--border);
  vertical-align: middle;
  font-size: 13px;
}
.data-table tbody tr:last-child td { border-bottom: none; }
.data-table tbody tr:hover td { background: var(--surface); }
.data-table tbody tr.row-link { cursor: pointer; }
.data-table tbody tr.row-link:hover td a { text-decoration: underline; }

/* A row that is deliberately inert: the Sites page's retired archive. Muted because the rows are
 * history rather than work, and given the DEFAULT cursor explicitly — the table beside it is
 * clickable, so a row that looked the same and did nothing would read as broken rather than as
 * closed. There is no :hover rule and nothing to underline: these rows carry no link at all, which
 * is what actually makes them unclickable (rows.js follows a row's first link). */
.data-table tbody tr.row-retired td { color: var(--text-secondary); opacity: 0.65; cursor: default; }

/* ---- Forms ---- */
.form-group { margin-bottom: 16px; }
.form-label {
  display: block;
  font-size: 11px; font-weight: 700;
  color: var(--text-secondary);
  text-transform: uppercase; letter-spacing: 0.6px;
  margin-bottom: 5px;
}
.form-input, .form-select, .form-textarea {
  width: 100%;
  padding: 8px 12px;
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  font-size: 14px; color: var(--text-primary);
  background: white;
}
.form-input:focus, .form-select:focus, .form-textarea:focus {
  outline: none;
  border-color: var(--guidant-navy);
  box-shadow: 0 0 0 3px rgba(27, 50, 94, 0.1);
}
.form-textarea { min-height: 80px; resize: vertical; }
.form-hint     { font-size: 12px; color: var(--text-secondary); margin-top: 4px; }
.form-row      { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.form-required { color: var(--color-open); }
/* ---- Notices above a form ----
   What an upload page says after a POST: the applied summary, and the refusals that stop one.
   The four call sites (rosters, correlation ids) had been writing `.alert alert-success` and
   `.alert alert-danger` since those pages were built, and NEITHER CLASS EXISTED here — so an applied
   upload printed its summary as an unstyled line above the form and read as a page that had simply
   reloaded. That is what "no feedback on success" was. Deliberately quiet: an outline and a tinted
   ground, not a filled banner, because these sit above a form the operator is still working in. */
.alert {
  padding: 10px 14px;
  border: 1px solid;
  border-radius: var(--radius-md);
  font-size: 13px; line-height: 1.5;
}
.alert-success { border-color: var(--color-complete); background: var(--wash-green); color: var(--ink-green); }
.alert-danger  { border-color: var(--danger);         background: var(--wash-red); color: var(--ink-red); }


/* ---- The filter row ----
   ONE row of dropdowns above a list, on every page that has filters (internal/layout/filters.templ,
   2026-08-07). It replaced the stacked .filter-bar chip rows, which cost a third of the viewport on
   the pages with three axes and gave each of them an "All" chip that did not say what it cleared.

   A Tier B page's row is a <form> and a Tier A page's is a <div>; they are styled by the same rule
   because a reader must not be able to tell the tiers apart (routing-and-pages.md §The shared
   contract). It wraps, so a narrow viewport gets two short rows rather than a horizontal scroll. */
.filter-row {
  display: flex; align-items: center; flex-wrap: wrap;
  gap: 8px;
  margin-bottom: 16px;
}
/* Sized to their content, not to the row: a dropdown stretched across the width of the page is a
   control that looks like it is missing something. The max stops the longest site name from
   pushing the rest of the axes onto a second line by itself. */
.filter-row .form-select,
.filter-row .form-input {
  width: auto; max-width: 260px;
  padding: 5px 10px; font-size: 12px;
}
.filter-row .filter-text { min-width: 200px; }
/* A date is eight characters and a picker glyph. Both ends of a range get the same width so the row
   reads as one control, and neither grows: the × sits inside the field, which is what replaced the
   Apply and Clear buttons that used to follow it. */
.date-field { position: relative; display: inline-flex; align-items: center; }
.filter-row .date-input { width: 11rem; padding-right: 26px; }
.field-clear {
  position: absolute; right: 4px;
  border: none; background: none; cursor: pointer;
  padding: 0 4px; line-height: 1;
  font-size: 15px; color: var(--text-secondary);
}
.field-clear:hover { color: var(--danger); }

/* ---- Filter chips bar ----
   NO LIST FILTERS WITH CHIPS ANY MORE (2026-08-07) — every axis is a dropdown in a .filter-row, and
   the dashboard's service-line chips went the same way. .filter-bar survives as a plain row of
   controls (the internal job workbench uses it); .filter-chip is currently rendered by nothing. Kept
   because a chip row is still the right control for a small fixed vocabulary and the next one should
   not be reinvented from scratch — but if it is still unused at the next sweep, delete it. */
.filter-bar { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; align-items: center; }
/* A small form inside a card header — the dashboard panels' site picker. It borrows the compact
   scale a filter control has rather than the full-width one .form-select carries on a real form. */
.filter-form { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.filter-form .form-select { width: auto; max-width: 260px; padding: 5px 10px; font-size: 12px; }
.filter-form-label {
  font-size: 11px; font-weight: 700;
  color: var(--text-secondary);
  text-transform: uppercase; letter-spacing: 0.6px;
}
.filter-chip {
  padding: 5px 14px;
  border-radius: 20px;
  font-size: 12px; font-weight: 500;
  border: 1px solid var(--border);
  background: white; cursor: pointer;
  color: var(--text-secondary);
  transition: all 0.15s;
  white-space: nowrap;
}
.filter-chip:hover { border-color: var(--guidant-navy); color: var(--guidant-navy); }
.filter-chip.active { background: var(--guidant-navy); color: white; border-color: var(--guidant-navy); }
/* ---- Modals ---- */
.modal-overlay {
  position: fixed; inset: 0;
  background: rgba(0, 0, 0, 0.45);
  z-index: 300;
  display: flex; align-items: center; justify-content: center;
  padding: 16px;
}
.modal {
  background: white;
  border-radius: var(--radius-lg);
  width: 520px; max-width: 100%;
  box-shadow: var(--shadow-lg);
  max-height: 92vh;
  display: flex; flex-direction: column;
}
.modal-sm { width: 420px; }
.modal-lg { width: 680px; }
.modal-header {
  padding: 20px 24px 16px;
  border-bottom: 1px solid var(--border);
  display: flex; align-items: flex-start; justify-content: space-between;
  flex-shrink: 0;
}
.modal-title { font-size: 16px; font-weight: 700; color: var(--guidant-navy); }
.modal-close {
  background: none; border: none; cursor: pointer;
  font-size: 22px; color: var(--text-secondary);
  line-height: 1; padding: 2px;
  flex-shrink: 0; margin-left: 12px;
}
.modal-body   { padding: 20px 24px; overflow-y: auto; flex: 1; }
/* One outcome in the remediation modal: a radio, its name, and the sentence saying what it does.
   The whole box is the label, so the sentence is part of the target rather than text beside it.

   A TWO-COLUMN GRID (2026-08-09), where it was a plain block. Two things were wrong with that. The
   sentence started at the box's left edge, under the radio rather than under the word it explains,
   so a column of options read as a ragged left margin. And the radio sat on the first text line,
   which put it against the name rather than against the option — noticeably off-centre on the
   options whose sentence wraps to two lines.

   The radio spans both rows and centres itself across them; the name and the sentence share the
   second column, so they start at the same x whatever the sentence's length. */
.outcome-option {
  display: grid;
  grid-template-columns: auto 1fr;
  column-gap: 8px;
  align-items: center;
  padding: 8px;
  border: 1px solid var(--border);
  border-radius: 6px;
  margin-bottom: 6px;
  cursor: pointer;
}
.outcome-option > input[type="radio"] {
  grid-row: 1 / span 2;
  align-self: center;
  margin: 0;
}
.outcome-option:hover { border-color: var(--guidant-navy); }
.modal-footer {
  padding: 16px 24px;
  border-top: 1px solid var(--border);
  display: flex; gap: 8px; justify-content: flex-end;
  flex-shrink: 0;
}

/* ---- Pagination ---- */
.pagination {
  display: flex; align-items: center; justify-content: space-between;
  padding: 12px 16px;
  border-top: 1px solid var(--border);
  font-size: 13px; color: var(--text-secondary);
}
.pagination-btns { display: flex; gap: 4px; }
.page-btn {
  padding: 4px 10px;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: white; cursor: pointer; font-size: 12px;
  transition: all 0.15s;
}
.page-btn:hover  { border-color: var(--guidant-navy); color: var(--guidant-navy); }
.page-btn.active { background: var(--guidant-navy); color: white; border-color: var(--guidant-navy); }
/* Pager controls are anchors, so they must not look like body links. */
a.page-btn { text-decoration: none; color: var(--text-primary); }
a.page-btn.active { color: white; }
/* Prev/Next at the ends of the range render as spans rather than vanishing, so the control keeps
   its shape as the reader moves through the pages. */
.page-btn.disabled { opacity: 0.45; cursor: default; }
.page-btn.disabled:hover { border-color: var(--border); color: var(--text-primary); }

/* ---- Dashboard charts -------------------------------------------------------------------------
 *
 * Every chart on the dashboard is divs with percentage heights, sized in Go
 * (feature/portfolio/view.go §Geometry). There is no charting library and no client-side drawing
 * code, so what is on screen is what the query returned. ONE shape is left after the 2026-08-21
 * redesign — the trend chart's row of vertical stacked columns — where there were three. */

/* One legend per chart, above it. The trend chart's severity ramp and the mini-map's compliance
   colours are both keyed with this. */
.chart-legend { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; font-size: 11px; }
.chart-legend-item { display: flex; align-items: center; gap: 5px; }

/* THE TWO STACKED-BAR PANELS WENT ON 2026-08-21 (decision 29), and their CSS with them: the
   Scheduling Status bars, the Open Remediations by Age bars, the cadence timeline strip under each
   scheduling bar, and the container queries that hid a count too wide for its own slice. The
   dashboard draws no horizontal bars at all now — the scheduling reading is the mini-map's dot
   colours and the aging one is gone — so `.chart-rows`, `.chart-row*`, `.stack-*` and `.cadence-*`
   had no markup left. The legend above stays: the trend chart and the mini-map both key themselves
   with it. */

/* ---- The weekly trend chart --------------------------------------------------------------------
 *
 * A counted y-axis beside a plot area that holds three layers: gridlines behind, columns, and a row
 * of tilted labels below. The axis, the gridlines and the column heights all divide by the same
 * SCALE — the top tick, not the tallest week — so a column's top and the label it lines up with
 * cannot drift apart. */
.trend-plot { display: flex; align-items: flex-start; gap: 6px; }
.trend-yaxis {
  position: relative; width: 26px; height: 160px; flex-shrink: 0;
}
.trend-tick {
  position: absolute; right: 0;
  transform: translateY(50%);
  font-size: 10px; color: var(--text-secondary); font-variant-numeric: tabular-nums;
}
.trend-area { position: relative; flex: 1; min-width: 0; }
.trend-grid { position: absolute; left: 0; right: 0; bottom: 0; height: 160px; }
.trend-gridline {
  position: absolute; left: 0; right: 0;
  border-top: 1px solid var(--border); opacity: 0.6;
}
.trend-chart {
  position: relative; display: flex; align-items: flex-end; gap: 2px;
  height: 160px;
}
.trend-column { flex: 1; height: 100%; display: flex; align-items: flex-end; min-width: 0; }
/* column-reverse is what puts the FIRST segment at the bottom, and the segments arrive worst-first
   (portfolio/view.go §trendBars). Reversing this quietly stands the ladder on its head. */
.trend-stack {
  width: 100%; display: flex; flex-direction: column-reverse;
  border-radius: 2px 2px 0 0; overflow: hidden;
}
.trend-column:hover .trend-stack { filter: brightness(1.08); }
/* One label per week, tilted. The row mirrors .trend-chart's flex layout exactly — same gap, same
   flex basis — so label n sits under column n; changing one without the other shears the axis.
   Each label is absolutely positioned inside its own cell so its length cannot widen the cell and
   push every later column out of alignment. */
.trend-labels { display: flex; gap: 2px; height: 42px; margin-top: 4px; }
.trend-label { flex: 1; min-width: 0; position: relative; }
.trend-label > span {
  position: absolute; top: 0; right: 50%;
  transform: rotate(-60deg); transform-origin: top right;
  font-size: 9px; line-height: 1; white-space: nowrap;
  color: var(--text-secondary);
}
/* The standing figure at the right-hand end (portfolio/view.go §nowBar). It is a different KIND of
   number from the 52 weeks — what is open at this instant, not at the start of a week — so it gets a
   rule down its left edge and a little air, while keeping the same width, scale and colours as the
   columns it is read against. The class lands on the column AND on its label, and both get the same
   margin: the two rows mirror each other's flex layout, and shifting one alone shears the axis. */
.trend-column.trend-now, .trend-label.trend-now {
  margin-left: 10px;
  border-left: 1px dashed var(--border);
  padding-left: 10px;
}
/* Its label is the only one worth reading at a glance, so it sits upright and dark where the dates
   are tilted and grey. Same origin as the rotated ones, minus the rotation. */
.trend-label.trend-now > span {
  transform: none; right: auto; left: 10px;
  font-size: 10px; font-weight: 600;
  color: var(--text-primary);
}

/* ---- The dashboard's layout (2026-08-21, decision 29) ------------------------------------------
 *
 * ONE grid holds all six cards; the spans make the three rows. The mini-map takes two columns
 * because the map is the card's whole content and wants the width, and the trend chart takes three
 * because it draws fifty-two columns and cannot be read at a third of the page.
 *
 * A single grid rather than three — the middle row's three cards line up with the two above them
 * only if one container is measuring all of them. `align-items: stretch` is the default and is what
 * makes cards in a row the same height whatever their contents; the scrolling card is what keeps the
 * longest of them from setting that height. */
.dash-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 16px;
  /* HOW TALL THE MINI-MAP IS, and therefore how tall the top row is. Two other rules derive from it:
     the map's own max-width (its ratio applied to this) and the scrolling card's ceiling beside it.
     They are one number because the two cards share a grid row — change this and both follow. */
  --dash-map-h: 420px;
}
.dash-map-card { grid-column: span 2; }
.dash-wide     { grid-column: 1 / -1; }

/* The breakpoints live HERE rather than with the other grids in base.css, and that is not a filing
   choice: components.css loads after base.css, so an unconditional .dash-grid here beats a media
   query there and the page stayed three columns however narrow the window got.
   Two columns before one: the mini-map spanning both is still a map worth reading, where a
   one-column map is a strip. The spans are reset in the same breakpoint that changes the column
   count, or a card asks for three columns out of two and the grid grows an implicit one. */
@media (max-width: 1100px) {
  .dash-grid     { grid-template-columns: repeat(2, 1fr); }
  .dash-map-card { grid-column: span 2; }
}
@media (max-width: 768px) {
  .dash-grid                 { grid-template-columns: 1fr; }
  .dash-map-card, .dash-wide { grid-column: auto; }
}

/* Every card leads with its count. Tabular figures because six of them sit in a grid and
   proportional digits make the row ragged. */
.dash-count {
  font-size: 28px; font-weight: 700; line-height: 1;
  color: var(--text-primary);
  font-variant-numeric: tabular-nums;
}
/* The count and the title are ONE element, because .card-header is a flex row that spreads its
   children to put a control at the far end — handed the pair loose, it parks them at opposite ends
   of the card. On the two cards whose heading navigates, the anchor wraps that element. */
.dash-head {
  display: flex; align-items: baseline; gap: 8px; min-width: 0;
}
/* A card's name does not break. Four of the six are two words, and "Open Remediations" wrapped at
   its space the moment the grid dropped to two columns — a heading on two lines beside a count on
   one reads as two headings, and it changed the card's own height against its neighbours. */
.dash-head .card-title { white-space: nowrap; }
.dash-head-link { color: inherit; text-decoration: none; min-width: 0; }
.dash-head-link:hover { text-decoration: underline; }
/* The whole mini-map card navigates (rows.js), so the pointer says so over all of it. */
.card.row-link { cursor: pointer; }
.card.row-link:hover .dash-head-link { text-decoration: underline; }

/* ---- The mini-map ------------------------------------------------------------------------------
 *
 * The static US map at card size, with a dot per facility. The projection is the full map's
 * (sites/map.go §projectLatLong), so a facility sits in the same place on both. The dots are not
 * links and take no pointer events: the card itself is the link, and a dot swallowing the click
 * would make the card's destination depend on where in it a reader happened to press. */
/* AS BIG AS THE ROW ALLOWS, up to a ceiling. The card is two columns wide so the map has width to
   spend, but the SVG is 1.38:1 — unbounded, two columns of a wide screen make it tall enough to push
   the whole middle row below the fold, and a dashboard showing one card is not one.
   The box keeps the SVG's own ratio, so the image fills it exactly and the dots' percentages stay
   true; the ceiling is expressed as a max-width derived from that ratio, because capping the HEIGHT
   of a box with a fixed width would squash the picture instead of shrinking it. */
.dash-map {
  position: relative;
  width: 100%;
  aspect-ratio: 1028 / 746;
  /* The ceiling is expressed as a max-WIDTH derived from the SVG's ratio, because capping the height
     of a box with a fixed width would squash the picture instead of shrinking it. */
  max-width: calc(var(--dash-map-h) * 1028 / 746);
  margin: 0 auto;
}
.dash-map-img { display: block; width: 100%; height: 100%; }
.dash-dot {
  position: absolute;
  /* The dot is sized to be READ at card scale; the border stays 1.5px whatever the dot does, since
     it is a separator between overlapping dots rather than part of the mark, and thickening it with
     the dot would close the gaps a cluster needs. The margins are half the size, centring the dot on
     its facility's point. */
  width: 13px; height: 13px;
  margin: -6.5px 0 0 -6.5px;
  border-radius: 50%;
  border: 1.5px solid var(--card);
  box-shadow: 0 1px 2px rgba(14,25,47,0.25);
  pointer-events: none;
}
.dash-map-legend {
  display: flex; gap: 12px; flex-wrap: wrap;
  margin-top: 10px; font-size: 11px;
}

/* ---- The severity list -------------------------------------------------------------------------
 *
 * One row per level: the level's chip, and its count hard against the right-hand edge so the five
 * figures form a column the eye can add up. */
/* `dash-fill` lets a card's body take the card's full height, for the ONE card whose contents can
   use it: the scrolling list beside the map. Everything else is top-aligned, because a list of five
   and a list of two must start in the same place. */
.dash-fill { display: flex; flex-direction: column; }
.dash-fill > .card-body { flex: 1; display: flex; flex-direction: column; min-height: 0; }

.dash-levels { display: flex; flex-direction: column; }
.dash-level {
  display: flex; align-items: center; justify-content: space-between; gap: 8px;
  padding: 7px 4px;
  border-bottom: 1px solid var(--border);
  color: inherit; text-decoration: none;
}
.dash-level:last-child { border-bottom: none; }
a.dash-level:hover { background: var(--surface); text-decoration: none; }
.dash-level-n {
  font-size: 20px; font-weight: 700; line-height: 1;
  font-variant-numeric: tabular-nums;
}

/* ---- The three record lists --------------------------------------------------------------------
 *
 * A row is an anchor, not a div wrapped in one: it is a single destination, so it should be
 * focusable and openable in a new tab without rows.js involved at all.
 *
 * The name takes the free space and truncates; the date and the service line are fixed-width facts
 * that must not be the thing that wraps. */
.dash-list { display: flex; flex-direction: column; }
.dash-row {
  display: flex; align-items: baseline; gap: 8px;
  padding: 7px 4px;
  border-bottom: 1px solid var(--border);
  font-size: 12px;
  color: inherit; text-decoration: none;
}
.dash-row:last-child { border-bottom: none; }
.dash-row:hover { background: var(--surface); text-decoration: none; }
.dash-row-date {
  flex-shrink: 0; color: var(--text-secondary);
  font-variant-numeric: tabular-nums;
}
.dash-row-name {
  flex: 1; min-width: 0;
  font-weight: 600;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.dash-row-line { flex-shrink: 0; color: var(--text-secondary); }
.dash-row-span {
  flex-shrink: 0; color: var(--text-secondary);
  font-variant-numeric: tabular-nums;
}

/* THE SCROLLING LIST — the Upcoming Inspections card only (page.templ §upcomingCard). How many rows
   show is the LAYOUT's answer rather than a row count of its own, which is what earned this card the
   place beside the map.
   `flex: 1` fills the card when the map is the taller of the two, and `min-height: 0` is what lets it
   shrink at all — a flex item's default `min-height: auto` refuses to go below its content.
   IT STILL NEEDS A CEILING, and that is the part that is easy to get wrong: a grid row is as tall as
   its tallest item, so with no cap a long list stretches the row and the MAP grows to match it. The
   cap is the map's own height plus its legend (10px margin + ~13px of text), which lands this card's
   height exactly on the map card's — flush, and never the one deciding the row.
   BELOW THE THREE-COLUMN BREAKPOINT the map takes a row of its own and this card sits beside a
   five-row one instead, so the cap falls back to six rows — what a five-row card plus its "see N
   more" line stands at — PLUS A SLIVER of the seventh, because macOS hides its scrollbars until
   something scrolls and a box cut exactly at a row boundary looks like a list that ends there. A row
   is ~30px at the card's font size. */
.dash-scroll { flex: 1; min-height: 0; max-height: 192px; overflow-y: auto; }
@media (min-width: 1101px) {
  .dash-scroll { max-height: calc(var(--dash-map-h) + 24px); }
}

/* The last line of a capped card. Set apart from the rows above it: it is not a record. */
.dash-more {
  display: block; padding: 8px 4px 0;
  font-size: 12px; font-weight: 600;
}

/* ---- Combobox (web/static/js/combobox.js) ------------------------------------------------------
 *
 * A text input over a scrollable listbox: the same job a <select> does, for a list too long to
 * scroll. The input reuses .form-select's box so it sits in a header identically to the control it
 * replaced. */
.combobox { position: relative; }
/* An arrow until it has focus: at rest this reads as the dropdown it replaced, and a text caret
   over it invites the reader to type before there is a list to type against. It becomes a caret on
   focus, which is when typing starts doing something. */
.combobox-input { cursor: default; }
.combobox-input:focus { cursor: text; }
.combobox-list {
  /* ABOVE THE SIDEBAR (z-index 100), not merely above the page. The dashboard's pickers sit in a
     card header in the middle of the content, so 30 was enough there; the filter row on /reports and
     /remediations starts at the content's left edge, and the list was rendering underneath the nav.
     Still below the topbar (200) and the modal overlay (250), both of which should cover it. */
  position: absolute; z-index: 150; top: calc(100% + 2px); left: 0; right: auto;
  min-width: 100%; max-width: 320px;
  /* Scrollable rather than tall: a hundred sites must not push a list off the bottom of the page. */
  max-height: 260px; overflow-y: auto;
  margin: 0; padding: 4px 0; list-style: none;
  background: var(--card); border: 1px solid var(--border-strong);
  border-radius: var(--radius-sm); box-shadow: var(--shadow-md);
  font-size: 12px;
}
.combobox-list [role="option"] {
  padding: 5px 10px; cursor: pointer; white-space: nowrap;
  overflow: hidden; text-overflow: ellipsis;
}
.combobox-list [role="option"]:hover,
.combobox-list [role="option"].active { background: var(--surface-muted); }
.combobox-empty { padding: 5px 10px; color: var(--text-secondary); }

/* A panel's service-line buttons, above its chart. The site picker lives in the card header. */
.card-header-actions { display: flex; align-items: center; gap: 12px; }
.card-header .filter-form { margin: 0; }
/* The picker is a control in a header sized for text, so it is smaller than a form's select and
   must not stretch: .card-header is a flex row and an unconstrained select would push the title. */
.card-header .form-select { width: auto; max-width: 220px; padding: 4px 8px; font-size: 12px; }
/* A list grows away from the edge it is anchored to. In a filter row that edge is the left one
   (above); in a card header the control is against the right, and a left-anchored list would run
   off the card. */
.card-header .combobox-list { left: auto; right: 0; }

/* ---- Topbar (white bar, navy as accent) ---- */
.topbar {
  position: fixed; top: 0; left: 0; right: 0;
  height: var(--topbar-height);
  background: var(--nav-topbar);
  color: var(--guidant-navy);          /* default currentColor for icon buttons */
  border-bottom: 1px solid var(--border);
  box-shadow: 0 1px 2px rgba(14,25,47,0.04);
  z-index: 200;
  display: flex; align-items: center;
  padding: 0 12px; gap: 10px;
}
.topbar-brand {
  display: flex; align-items: center; gap: 8px;
  text-decoration: none; flex-shrink: 0;
}
.topbar-brand:hover { text-decoration: none; }
.brand-arc {
  width: 32px; height: 32px;
  background: var(--guidant-yellow);
  border-radius: 6px;
  display: flex; align-items: center; justify-content: center;
  font-weight: 900; font-size: 18px;
  color: var(--guidant-navy);
  flex-shrink: 0;
  line-height: 1;
}
.brand-name    { color: var(--guidant-navy); font-size: 12px; font-weight: 700; letter-spacing: 1.5px; text-transform: uppercase; line-height: 1.2; }
.brand-tagline { color: var(--text-secondary); font-size: 9px; }
.topbar-site-switcher {
  background: var(--surface); border: 1px solid var(--border);
  border-radius: 6px; padding: 6px 10px;
  color: var(--text-primary); font-size: 12px; cursor: pointer;
  max-width: 210px;
}
.topbar-site-switcher option { background: #fff; color: var(--text-primary); }
.topbar-spacer { flex: 1; }
/* .topbar-search was removed 2026-07-31 with the dead global search box it styled (goal 80).
   The spacer above now carries the topbar's whole gap, which is what it was always for. */
/* The sidebar toggle, and it is HIDDEN by default — revealed only inside the 768px block below,
   which is the same rule that takes the sidebar off-canvas. The two are one decision: the button
   adds .mobile-open, and .mobile-open has a rule at no other width, so above the breakpoint the
   sidebar is already on screen and pressing this changes nothing. It rendered at every width until
   2026-08-11 and was a dead control on every desktop page (goal 80).
   Stated as none-then-reveal rather than a min-width query so both halves stay under one condition.
   Guarded by internal/layout/responsive_test.go. */
.topbar-icon-btn {
  background: none; border: none; color: var(--guidant-navy); cursor: pointer;
  width: 36px; height: 36px; border-radius: 6px;
  display: none; align-items: center; justify-content: center;
  font-size: 18px; position: relative;
  transition: background 0.15s; flex-shrink: 0;
}
.topbar-icon-btn:hover { background: rgba(27,50,94,0.06); }
.user-btn {
  display: flex; align-items: center; gap: 8px;
  background: var(--surface); border: 1px solid var(--border);
  border-radius: 6px; padding: 5px 10px;
  cursor: pointer; color: var(--text-primary); font-size: 13px;
}
.user-btn:hover { background: var(--surface-muted); }
.user-avatar {
  width: 26px; height: 26px; border-radius: 50%;
  background: var(--guidant-yellow); color: var(--guidant-navy);
  display: flex; align-items: center; justify-content: center;
  font-size: 11px; font-weight: 700; flex-shrink: 0;
}

/* ---- Sidebar (one deep-ink anchor against a light frame) ---- */
.sidebar {
  position: fixed; top: var(--topbar-height); left: 0; bottom: 0;
  width: var(--sidebar-width);
  background: var(--guidant-ink);
  border-right: 1px solid rgba(0,0,0,0.25);
  box-shadow: 1px 0 0 rgba(0,0,0,0.15);
  display: flex; flex-direction: column;
  overflow-y: auto; overflow-x: hidden;
  z-index: 100;
  transition: width 0.25s, transform 0.25s;
}
/* NOTHING SETS `collapsed`. A narrow desktop sidebar was styled and never wired — no script and no
   template adds the class, here or on .app-body. It is not what the topbar toggle drives; that one
   adds .mobile-open and is offered only below 768px. Left in place rather than deleted because
   base.css reserves the collapsed width and responsive_test.go asserts that reservation is released
   on a phone, so the two selectors still have to agree. Wire it or remove both halves together. */
.sidebar.collapsed { width: var(--sidebar-collapsed-width); }
.sidebar.collapsed .nav-label        { display: none; }
.sidebar.collapsed .nav-section-label { display: none; }
.nav-section-label {
  padding: 16px 16px 4px;
  font-size: 10px; font-weight: 700;
  color: rgba(255,255,255,0.42);
  text-transform: uppercase; letter-spacing: 1.2px;
  white-space: nowrap;
}
.nav-item {
  display: flex; align-items: center; gap: 12px;
  padding: 10px 16px;
  color: rgba(255,255,255,0.72);
  cursor: pointer;
  transition: all 0.15s;
  font-size: 13px; font-weight: 500;
  text-decoration: none;
  white-space: nowrap;
  border-left: 3px solid transparent;
}
.nav-item:hover { background: rgba(255,255,255,0.08); color: #fff; text-decoration: none; }
.nav-item.active {
  background: rgba(255,255,255,0.06);
  color: #fff;
  font-weight: 700;
  border-left-color: var(--guidant-yellow);   /* yellow finally has a home, on dark */
}
.nav-item.active .nav-icon { color: var(--guidant-yellow); }
.nav-icon { font-size: 15px; width: 20px; text-align: center; flex-shrink: 0; }
.nav-divider { border: none; border-top: 1px solid rgba(255,255,255,0.10); margin: 8px 0; }

/* ---- Account selector (staff only; 2026-08-06, decision 21) ----
   The control the whole staff experience hangs off, so it sits above the nav rather than in it and
   is separated by a rule: it does not choose a PAGE, it chooses what every page below it is about.
   Same <details> mechanism as the notification bell and the user menu — open on click, no
   JavaScript, no client-side state. */
.acct-switch { border-bottom: 1px solid rgba(255,255,255,0.10); }
.acct-switch > summary { list-style: none; cursor: pointer; }
.acct-switch > summary::-webkit-details-marker { display: none; }
.acct-switch-btn {
  display: flex; align-items: center; gap: 8px;
  padding: 12px 16px;
  color: #fff;
  transition: background 0.15s;
}
.acct-switch-btn:hover { background: rgba(255,255,255,0.08); }
.acct-switch-label {
  font-size: 10px; font-weight: 700;
  color: rgba(255,255,255,0.42);
  text-transform: uppercase; letter-spacing: 1.2px;
  flex-shrink: 0;
}
/* The selected account's name takes the space that is left and truncates rather than wrapping: a
   two-line control would shift every nav item below it depending on which customer is selected. */
.acct-switch-name {
  flex: 1; min-width: 0;
  font-size: 13px; font-weight: 700;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.acct-switch[open] > summary .acct-switch-btn { background: rgba(255,255,255,0.08); }
/* Scrolls rather than growing: a hundred-customer list must not push the navigation off screen. */
.acct-switch-panel {
  max-height: 320px; overflow-y: auto;
  background: rgba(0,0,0,0.22);
  padding: 4px 0;
}
.acct-switch-item {
  display: block;
  padding: 8px 16px;
  font-size: 13px;
  color: rgba(255,255,255,0.72);
  text-decoration: none;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  border-left: 3px solid transparent;
}
.acct-switch-item:hover { background: rgba(255,255,255,0.08); color: #fff; text-decoration: none; }
.acct-switch-item.active { color: #fff; font-weight: 700; border-left-color: var(--guidant-yellow); }
/* Collapsed, the name and label have nowhere to go; the chevron alone still opens the panel. */
.sidebar.collapsed .acct-switch-label,
.sidebar.collapsed .acct-switch-name { display: none; }

/* ---- Notifications overlay ---- */
.notif-overlay {
  position: fixed; top: calc(var(--topbar-height) + 6px); right: 10px;
  width: 380px; max-height: 480px;
  background: white; border-radius: var(--radius-md);
  border: 1px solid var(--border);
  box-shadow: var(--shadow-lg);
  z-index: 250;
  display: flex; flex-direction: column;
}
.notif-header {
  padding: 12px 16px;
  border-bottom: 1px solid var(--border);
  display: flex; align-items: center; justify-content: space-between;
  flex-shrink: 0;
}
.notif-header-title { font-size: 14px; font-weight: 700; }
.notif-header-actions { display: flex; align-items: center; gap: 12px; }
.notif-list { overflow-y: auto; flex: 1; }
.notif-item {
  padding: 12px 16px;
  border-bottom: 1px solid var(--border);
  transition: background 0.1s;
}
.notif-item:last-child { border-bottom: none; }
.notif-item:hover { background: var(--surface); }
.notif-message { font-size: 13px; line-height: 1.45; margin-bottom: 3px; }
.notif-time    { font-size: 11px; color: var(--text-secondary); }
.notif-footer  { padding: 10px 16px; border-top: 1px solid var(--border); text-align: center; font-size: 13px; flex-shrink: 0; }

/* ---- CSV export popover ---- */
.csv-popover-wrap { position: relative; display: inline-block; }
.csv-popover {
  position: absolute; right: 0; top: calc(100% + 6px);
  background: white; border-radius: var(--radius-md);
  border: 1px solid var(--border);
  box-shadow: var(--shadow-md);
  padding: 16px; width: 260px; z-index: 200;
}
.csv-popover-title    { font-size: 13px; font-weight: 700; margin-bottom: 12px; }
.csv-popover-section  { font-size: 11px; font-weight: 700; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; }
.csv-popover label    { display: flex; gap: 8px; margin-bottom: 6px; font-size: 13px; align-items: center; cursor: pointer; }

/* ---- Service line cards ---- */
.service-card {
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  padding: 16px;
}
.service-card-label { font-size: 11px; font-weight: 700; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; }
.service-card-name  { font-size: 15px; font-weight: 700; margin-bottom: 8px; }

/* ---- Type badges (report types) ---- */
.type-badge {
  display: inline-block;
  padding: 2px 7px; border-radius: 4px;
  font-size: 11px; font-weight: 700;
  text-transform: uppercase; letter-spacing: 0.5px;
}
.type-af { background: var(--wash-type-af); color: var(--guidant-navy); }
.type-ir { background: var(--wash-type-ir); color: var(--ink-type-ir); }
.type-ol { background: var(--wash-type-ol); color: var(--ink-type-ol); }
.type-lb { background: var(--wash-type-lb); color: var(--ink-type-lb); }

/* ---- Deliverable file rows (job page) ----
   Ported verbatim from working-mock/job.html's page-local <style> block. The mock declares these
   inline on the one page that uses them; here the job page is server-rendered from the shared
   sheets, so they live with the other components. */
.file-row {
  display: grid;
  grid-template-columns: 1fr 150px 90px 110px auto;
  gap: 12px; align-items: center;
  padding: 12px 0; border-bottom: 1px solid var(--border);
}
/* The row navigates into the viewer as a whole (rows.js), so it wants the same pointer a list row
   has. Written for the grid only: the document viewer's sidebar entries reuse .file-row as a plain
   block link, where the anchor already supplies the cursor. */
.file-row.row-link { cursor: pointer; }
.file-row.row-link:hover a { text-decoration: underline; }
.ft-badge {
  font-size: 11px; font-weight: 600;
  background: var(--surface); border: 1px solid var(--border); border-radius: 6px;
  padding: 2px 8px; color: var(--text-secondary); white-space: nowrap;
}
/* Superseded version history, indented beneath its current file. */
.hist {
  font-size: 12px; color: var(--text-secondary);
  padding: 6px 0 6px 12px; border-left: 2px solid var(--border); margin-left: 4px;
}

/* ---- Aging chart ----
   The stacked proportional bar (.aging-bar / .aging-segment) is the MOCK's, defined above in the
   shared section. An earlier vertical bar-chart variant was added here and redefined .aging-bar,
   silently overriding the mock's rule since it came later in the file — so the mock's stylesheet
   was NOT applying unchanged, which is the whole premise of copying it. Removed with the dashboard
   rebuild; do not reintroduce a selector the shared section already owns. */

/* KPI tiles sit in a responsive row that wraps rather than scrolling. */
.kpi-row {
  display: grid; gap: 12px;
  grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
}

/* ---- Notification bell (topbar) ----
   A pure-CSS dropdown on <details>/<summary>: opens on click, no JavaScript, so the shell stays
   server-rendered. */
.notif { position: relative; }
.notif > summary {
  list-style: none; cursor: pointer;
  display: flex; align-items: center; justify-content: center;
  width: 34px; height: 34px; border-radius: 8px;
  color: var(--text-secondary); position: relative;
}
.notif > summary::-webkit-details-marker { display: none; }
.notif > summary:hover { background: var(--surface); color: var(--guidant-navy); }
.notif-dot {
  position: absolute; top: 2px; right: 2px;
  min-width: 15px; height: 15px; padding: 0 3px;
  background: var(--danger); color: #fff;
  border-radius: 8px; font-size: 9px; font-weight: 700;
  display: flex; align-items: center; justify-content: center;
}
.notif-panel {
  position: absolute; top: 42px; right: 0; z-index: 50;
  width: 340px; max-height: 70vh; overflow-y: auto;
  background: #fff; border: 1px solid var(--border); border-radius: var(--radius-md);
  box-shadow: var(--shadow-md);
}
.notif-panel-head {
  padding: 12px 14px; font-weight: 700; font-size: 13px;
  border-bottom: 1px solid var(--border);
}
.notif-empty { padding: 20px 14px; color: var(--text-secondary); font-size: 13px; text-align: center; }
.notif-item { padding: 10px 14px; border-bottom: 1px solid var(--border); }
.notif-item:last-child { border-bottom: none; }
.notif-msg { font-size: 13px; line-height: 1.4; }
.notif-time { font-size: 11px; color: var(--text-secondary); margin-top: 3px; }

/* ---- Map pins ----
   The mock declares these inline in map.html; here the map is server-rendered so they live with the
   other components. Colours match the compliance tokens. */
.map-pin { position: absolute; transform: translate(-50%, -50%); text-decoration: none; }
.map-pin:hover { z-index: 10; }

/* A pin is a RING around a DOT: the dot's fill is compliance, the ring's black arc is open
   remediation count relative to the busiest pin on screen (sites/map.go §What a pin encodes). The
   two used to be one circle carrying a colour and a numeral, and the numeral read as a cluster
   count. The ring is drawn by a conic-gradient set inline — server-rendered for the first paint,
   recomputed by sites.js whenever a filter changes the denominator.

   Smaller than the 34px circle it replaces: there is no longer a number to fit inside it, and at
   portfolio scale the old size overlapped constantly. */
.pin-ring {
  display: block; box-sizing: border-box;
  width: 22px; height: 22px; border-radius: 50%;
  padding: 3px;
  /* The hairline keeps a white ring legible on pale basemap tiles and on the SVG's water blue. */
  box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35), var(--shadow-md);
  transition: transform 0.1s;
}
.pin-dot { display: block; width: 100%; height: 100%; border-radius: 50%; }
.map-pin:hover .pin-ring { transform: scale(1.25); }
.pin-current    { background: var(--color-current); }
.pin-scheduled  { background: var(--color-scheduled); }
.pin-coming-due { background: var(--color-coming-due); color: var(--ink-bronze); }
.pin-past-due   { background: var(--color-past-due); }
.pin-delinquent { background: var(--color-delinquent); }
/* Legend dots reuse the pin colours so the key matches the map. */
.legend-dot.pin-current    { background: var(--color-current); }
.legend-dot.pin-scheduled  { background: var(--color-scheduled); }
.legend-dot.pin-coming-due { background: var(--color-coming-due); }
.legend-dot.pin-past-due   { background: var(--color-past-due); }
.legend-dot.pin-delinquent { background: var(--color-delinquent); }

/* ---- Responsive sidebar ---- */
@media (max-width: 768px) {
  .sidebar { transform: translateX(-100%); }
  .sidebar.mobile-open { transform: translateX(0); }

  /* The toggle exists for exactly this width — see .topbar-icon-btn above. */
  .topbar-icon-btn { display: flex; }

  /* The sidebar is off-canvas above, so the room base.css reserves for it must be given back —
     the two rules are one decision and were not, which is what broke the phone layout (PL-01).
     220px of a 390px viewport left a 170px content column and every page scrolling sideways.
     Both selectors are needed: `.app-body.sidebar-collapsed` outranks `.app-body`, so releasing
     only the latter leaves a collapsed sidebar's 64px hole behind. Guarded by
     internal/layout/responsive_test.go. The open sidebar overlays the content on a phone rather
     than displacing it — .sidebar keeps position:fixed at every width. */
  .app-body,
  .app-body.sidebar-collapsed { padding-left: 0; }

  /* A deliverable row is a five-column grid whose four FIXED columns and their gaps come to more
     than a phone is wide before the file name gets a single pixel, so nothing done inside the
     columns can fit it — the grid itself has to give. Removing the "Read" button was the other
     half of this and not a substitute for it (goal 81, and page.templ §fileSlot).

     So the name takes a line of its own and everything else shares the one below: three columns
     sized to their content, an empty 1fr that absorbs the slack, and the download at the far right
     where a desktop reader already looks for it. Nothing is dropped — a file's type, version and
     date are each one short fact, and a reader on a phone is the one most likely to be checking
     they have the current version. */
  .file-row {
    grid-template-columns: auto auto auto 1fr auto;
    gap: 4px 12px;
  }
  .file-row > :first-child { grid-column: 1 / -1; }
  .file-row > :last-child  { grid-column: 5; }
}

/* ---- Tier A list filtering (routing-and-pages.md §Lists) ----
   A Tier A list ships every scoped row and narrows them in the browser, so a cold load of a
   filtered URL would paint the full set for a frame before collapsing. The inline head script in
   internal/layout/csp.go sets .js-filtering when — and only when — the URL carries a filter param,
   and the filtering module clears it once the rows are filtered — sites.js on the Sites page, list.js
   everywhere else (with a 1s timeout in the same inline script as the failsafe, so a module that
   never loads cannot leave the table permanently blank). `visibility`
   rather than `display` so the table keeps its layout box and the page does not reflow when the
   rows appear. */
.js-filtering .data-table tbody { visibility: hidden; }
/* The map view narrows the same way, so it needs the same guard: the static variant's pins are
   server-rendered, and a cold load of a filtered URL would paint all of them for a frame. */
.js-filtering .map-outer { visibility: hidden; }

/* Filter chips are anchors, but must not look like body links. The href carries the state into the
   URL; no Tier A route reads it server-side and none should. See list.js §chip click. */
a.filter-chip { text-decoration: none; }

/* ---- Topbar account menu ----
   Reuses the notification bell's <details> dropdown so there is one no-JavaScript popover
   mechanism in the shell, not two. The bell's summary is a 34px square icon target; the account
   chip is a wide name-over-role button, so the size constraints are released here and .user-btn
   supplies the appearance. The panel itself (position, width, shadow) is inherited unchanged. */
.user-menu > summary { width: auto; height: auto; border-radius: 6px; }
.user-menu > summary:hover { background: var(--surface-muted); }
.user-menu .notif-panel { width: 260px; }

/* ---- Numeric and equipment cells (app additions, not from the mock) ----
   The mock spells these as inline styles at each call site because its tables are built by hand in
   JavaScript. Server-rendered templates repeat markup far more, so the same intent is a class here.

   .num — numeric column. Right-aligned so digits line up on the decimal, and tabular figures so
   the columns do not shimmy between rows with different digits. Already used by ~20 cells across
   accounts, jobs, equipment, and findings, which had no matching selector until now. */
.num { text-align: right; font-variant-numeric: tabular-nums; }
.data-table th.num { text-align: right; }

/* .asset — an equipment tag (XFMR-007). Monospace and navy per the mock, and crucially nowrap:
   the tags carry a hyphen, so a narrow column breaks them mid-identifier ("XFMR-" / "007") and an
   asset tag split across two lines reads as two different assets. */
.asset {
  font-family: var(--font-mono); font-size: 12px;
  color: var(--guidant-navy); white-space: nowrap;
}

/* ---- Interactive map (Leaflet variant — new selectors only; the shared pin classes above are
   reused unchanged by map.js's divIcon markup) ---- */
#live-map { height: 72vh; min-height: 420px; }
/* Leaflet positions divIcons at the point with size [0,0]; center the reused pin on it. The pin
   inside a divIcon is the SAME anchor markup the static variant renders server-side, so one hover
   handler and one ring-recompute serve both maps. */
.map-divpin .map-pin { position: static; display: block; transform: translate(-50%, -50%); }
.map-divpin { background: none; border: none; }
/* The legend floats over the map; Leaflet panes reach z-index 700, controls 800. */
.map-outer .map-legend { z-index: 900; }

/* ---- Upload drop targets ----
   Every upload in the product is one of these (internal/layout/filedrop.templ, and the Publish
   page's own type targets below). Each is a <label> bound to a visually-hidden file input, which is
   why it is styled as a button rather than being one — the label is what opens the picker with no
   script running. It replaced a native file input with a submit button beside it: `.upload-controls`
   existed to hold those two on one line and is gone with them.

   Deliverable drop targets, added 2026-08-19; the rest followed on 2026-08-20. */
/* One target per file type on the Publish page's "Upload a new file" card
   (internal/feature/jobs/authoring.templ §dropTargets). */
.drop-targets { display: flex; flex-wrap: wrap; gap: 10px; }
.drop-target-form { display: flex; flex-direction: column; gap: 6px; }
.drop-target {
  display: flex; flex-direction: column; gap: 2px; justify-content: center;
  min-width: 180px; min-height: 68px;
  padding: 10px 14px;
  border: 2px dashed var(--border-strong);
  border-radius: var(--radius-md);
  background: var(--card);
  cursor: pointer;
  transition: border-color 120ms, background 120ms, opacity 120ms;
}
.drop-target:hover { border-color: var(--guidant-navy); background: var(--surface-muted); }
.drop-target-name { font-weight: 700; font-size: 13px; }
.drop-target-hint { font-size: 11px; color: var(--text-secondary); }

/* A LONE zone — one form, one target, nothing to choose (layout.FileDrop). It spans the field rather
   than sizing to its words, because there is no neighbour to line up against and a narrow dashed box
   in a wide card reads as a button that failed to stretch. */
.drop-target-wide { min-height: 76px; }
.form-group > .drop-target-wide { width: 100%; }

/* A FILE'S OWN ROW on Publish, which is a drop target for the next version of that file. It keeps
   the .file-row grid and takes only the dashed edge, because the row's job is still to show the
   file: a row styled like the big targets below would read as an empty slot rather than a document.
   The dashed edge is transparent until the row is hovered or dragged over, so a list of files does
   not read as a list of drop zones. */
.drop-row {
  border: 2px dashed transparent;
  border-bottom-color: var(--border);
  border-radius: var(--radius-sm);
  padding-left: 8px; padding-right: 8px;
  transition: border-color 120ms, background 120ms;
}
.drop-row:hover { border-color: var(--border-strong); background: var(--surface-muted); }
.drop-row.is-over {
  border-style: solid;
  border-color: var(--guidant-navy);
  background: var(--tint-blue);
}
/* The row's own controls, at its right-hand end. A <label> styled as a button opens the picker; the
   submit beside it is the no-script path and upload.js hides it. */
.file-row-actions { display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
.file-row-actions .btn { cursor: pointer; }
/* The no-script submit button. upload.js sets `hidden` on it once it has bound the change handler,
   and .btn's own `display` outranks the attribute's UA rule — the same trap .map-popup-store carries
   a note about — so the hiding has to be stated here. Without this the page shows a button that
   posts an empty form. */
.drop-target-submit[hidden] { display: none; }
.drop-target-holds { font-size: 11px; color: var(--text-secondary); }

/* WHILE A FILE IS OVER THE CARD. Targets whose MIME matches the dragged file are raised; the rest
   are dimmed rather than blocked — the author picked one deliberately, and a drop that silently does
   nothing is worse than one that lands where it was aimed (upload.js §markSuitable). */
.is-dragging .drop-target { opacity: 0.45; }
.is-dragging .drop-target.is-suitable { opacity: 1; border-color: var(--guidant-navy); }
/* Under the pointer. Solid rather than dashed: the target has stopped being an invitation and become
   the thing about to happen. */
.drop-target.is-over {
  opacity: 1;
  border-style: solid;
  border-color: var(--guidant-navy);
  background: var(--tint-blue);
}

/* ---- The map and its facility list (2026-08-19) ----
   The map view is a two-column grid: a scrolling index of every facility on it, then the drawing.
   The list is rendered only above three pins (internal/feature/sites/handler.go §mapListMinimum), so
   this whole column is absent rather than empty on a small portfolio.

   THE LIST IS ON THE LEFT since 2026-08-21, and it was on the right until then. It reads as the
   index of a set the page's search box narrows, so it belongs directly under that box rather than
   diagonally across from it. The markup order matches (map.templ) — no `order`, so the tab order and
   the reading order are the drawn one.

   THE LIST IS THE SAME HEIGHT AS THE MAP, and what buys that is the items being taken OUT OF FLOW:
   the column is the positioning context and `.map-sidelist-items` fills it absolutely, so the list
   contributes NO intrinsic height to the grid row and the map alone decides how tall the row is.
   `overflow-y: auto` on the column was the original attempt and is not enough on its own — a grid
   item's automatic minimum size stops the row COLLAPSING, but the row is still sized by the tallest
   item's content, so a hundred facilities stretched it and left the map floating at the top of a
   column taller than itself. */
.map-region {
  display: grid;
  grid-template-columns: 232px minmax(0, 1fr);
  gap: 12px;
  align-items: stretch;
}
.map-region > .map-outer { min-width: 0; }
.map-sidelist {
  position: relative;
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  background: var(--card);
  overflow: hidden;
}
.map-sidelist-items {
  position: absolute;
  inset: 0;
  list-style: none;
  margin: 0;
  padding: 4px;
  overflow-y: auto;
  overscroll-behavior: contain;
}
.map-sidelist-item > a {
  display: flex; align-items: center; gap: 8px;
  padding: 6px 8px;
  border-radius: var(--radius-sm);
  font-size: 12px;
  color: var(--text-primary);
  text-decoration: none;
}
.map-sidelist-item > a:hover { background: var(--surface-muted); }
/* The entry whose card is on the map. Hover alone cannot say it, because the pointer may be on the
   list while the card is being pinned open by the dwell timer. */
.map-sidelist-item.is-active > a { background: var(--tint-blue); }
.map-sidelist-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.map-sidelist-item[hidden] { display: none; }

/* Stacked on a narrow viewport: a 232px column beside a map is most of a phone. The list keeps a
   bounded height of its own there, since there is no map row to borrow one from. */
@media (max-width: 720px) {
  .map-region { grid-template-columns: 1fr; }
  /* A DEFINITE height, not a max: the items are out of flow, so there is nothing left to size the
     column by and `max-height` would leave it flat. */
  .map-sidelist { height: 220px; }
}

/* The leader line from a visible card to its pin (map.templ §mapLeader). Dashed so it reads as an
   annotation rather than as anything drawn on the map itself, and never a pointer target — the pin
   underneath it is the link. */
.map-leader {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  pointer-events: none;
  z-index: 940;
}
.map-leader[hidden] { display: none; }
.map-leader line {
  stroke: var(--guidant-navy);
  stroke-width: 2;
  stroke-dasharray: 4 3;
}

/* ---- Map popup ----
   Server-rendered once per pin into .map-popup-store and hidden there; map.js moves the hovered
   one into .map-popup-host and positions it. It is markup rather than JavaScript-built DOM because
   it carries customer data, and because both map variants must show the same popup.

   The store must stay hidden even though it also carries the `hidden` attribute: `.map-popup` sets
   display:block, which outranks the attribute's UA rule on the children. */
.map-popup-store { display: none !important; }
/* POSITIONED ENTIRELY BY map.js (§place), which is why there is no transform here any more. It was
   translate(-50%,-100%) with a negative margin — the card centred over its pin and lifted by its own
   height — and a transform cannot be clamped: a card summoned from the facility list belongs as
   often to a pin at the edge of the map as to one in the middle, and half of it fell outside the
   box. The leader line is what keeps the pairing readable once the card has slid. */
/* THE CARD TAKES THE POINTER since 2026-08-20, and until then deliberately did not. Its per-line
   dates are links to the inspections they name (map.templ §mapPopups), and a link inside a
   pointer-events:none card is a link nobody can click — the reader watches the cursor stay an arrow
   over something underlined. map.js is the other half: it keeps the card up while the pointer is
   inside it and dismisses on the way out (§wirePopups), so the card can be entered without
   flickering. The pin underneath stays reachable because the card is placed BESIDE it (§place). */
.map-popup-host {
  position: absolute; z-index: 950;
  pointer-events: auto;
}
.map-popup-host[hidden] { display: none; }
/* WIDER SINCE 2026-08-20 (was 250px): a line's row is a heading, a date slot one date wide, the link
   glyph's room and a chip, and at the old width the two-word heading wrapped onto a second line on
   every card. */
.map-popup {
  display: block; width: 300px;
  background: white; border-radius: var(--radius-md);
  padding: 10px 12px; box-shadow: var(--shadow-md);
  border: 1px solid var(--border);
  font-size: 12px;
}
.map-popup-name { font-weight: 700; font-size: 13px; }
.map-popup-meta { font-size: 12px; color: var(--text-secondary); margin-top: 2px; }

/* One row per service line: the line's name, then the same cell the list view's column for that
   line holds. They were pills on their own compliance colour until 2026-08-20 — the colour WAS the
   state, with a title attribute to name it — and they are the list's date-and-chip now, which says
   the state in a word as well as in a colour and leaves one drawing of a service line rather than
   two. The card's facility-level dates went at the same time; these say it per line.

   A grid rather than a flex wrap, so the dates of two lines line up under each other. */
.map-popup-lines { display: grid; gap: 4px; margin-top: 8px; }
.map-popup-line {
  display: grid; grid-template-columns: 84px 1fr;
  align-items: center; gap: 6px;
  font-size: 11px;
}
.map-popup-line-label { color: var(--text-secondary); font-weight: 600; }

/* The service-line cell — a date and, where the state earns one, a chip beside it. Shared by the
   table cell and the card row, because they are one rendering (page.templ §lineValue). The chip
   never wraps under the date: at this width that reads as two separate values. */
.line-cell { display: flex; align-items: center; gap: 6px; white-space: nowrap; }
.line-cell .chip { flex: none; }

/* ONE DATE'S WIDTH, whether or not there is a date and whether or not it is a link. `10ch` of the
   monospace face is exactly `2026-08-19` and the rest is the link glyph's own room, so every cell's
   date slot is the same box and the chips beside them line up down the column — without it a row
   showing a dash pulled its chip four characters left of its neighbours', and a column of chips at
   three offsets reads as three different kinds of thing. The GLYPH'S WIDTH is in the box for the
   same reason the dash is: it is only ever on some of the rows, so leaving it outside just moves
   the misalignment from the dashes to the plain dates. The dash is CENTRED in that box — a single
   character at the left edge of a ten-character slot reads as a truncated value rather than as an
   absence. */
.line-date { display: inline-block; min-width: calc(10ch + 13px); }
.line-date-none { text-align: center; }

/* The link affordance (uitext §common.iconLink) — the chevron a list row carries everywhere for "go
   to". A linked date is already in the app's link colour, which distinguishes a name in a column of
   names and nothing at all in a column of dates, since every cell here is a date. Lighter than the
   date it follows, because it is a mark ON the link rather than part of what the link says, and it
   comes to full strength with the underline on hover — the moment the pointer confirms what it was
   promising. Kept at the text's own size: a chevron shrunk to 10px reads as dirt on the screen,
   which is half of what was wrong with the arrow it replaced. */
.link-glyph { opacity: 0.5; margin-left: 4px; }
a:hover .link-glyph { opacity: 1; }

.map-popup-empty { margin-top: 8px; color: var(--text-secondary); }
.map-popup .sevbars { margin-top: 8px; }

/* ---- The severity-and-age miniature ----
   ONE template (sites/page.templ §severityBars) at two sizes: a map popup, where each level is named
   and its total printed, and the site list's Open cell, where neither fits and neither is needed. It
   modelled the dashboard's by-age card until 2026-08-21, when that card went (decision 29) and left
   the facilities page as the only page drawing an age mix at all. One bar per severity level, its LENGTH the level's count
   against the busiest level on that site, its COLOURS the age mix. No segment counts and no legend —
   at these widths a number cannot be printed legibly.

   Bars are laid out on a grid rather than per-row flex so the tracks of all five line up: a bar's
   length is the figure being read, and lengths measured from ragged origins compare badly. */
.sevbars { display: grid; gap: 3px; }
.sevbar { display: grid; grid-template-columns: 58px 22px 1fr; align-items: center; gap: 6px; }
.sevbar-label { font-size: 11px; color: var(--text-secondary); }
.sevbar-total { font-size: 11px; font-weight: 700; text-align: right; font-variant-numeric: tabular-nums; }
.sevbar-track { display: block; height: 8px; background: var(--surface-muted); border-radius: 2px; overflow: hidden; }
.sevbar-fill { display: flex; height: 100%; border-radius: 2px; overflow: hidden; }
.sevbar-seg { display: block; height: 100%; }

/* The table-cell variant: no label and no total column, so the bar is the whole row, and thinner —
   five of these plus their gaps have to sit inside one table row's height without stretching it. */
.sevbars-mini { gap: 2px; width: 64px; }
.sevbars-mini .sevbar { grid-template-columns: 1fr; gap: 0; }
.sevbars-mini .sevbar-track { height: 3px; border-radius: 1px; }
.sevbars-mini .sevbar-fill { border-radius: 1px; }

/* The Open cell: the count, then its shape. The number is deliberately not colour-coded (view.go
   §emphasizeOpen, removed 2026-08-06) — the compliance chip two cells left already carries urgency,
   and the miniature states the age mix the colour was gesturing at. */
/* RIGHT-ALIGNED, header included — the same rule .num applies to every other numeric column. It is
   what makes the miniatures line up: the chart is a fixed width at the right edge, so its left edge
   sits the same distance in on every row, and the count right-aligns against it. Left-aligned, the
   count's width set where the chart began, so a column of 4s and 108s drew a ragged staircase of
   charts. */
.col-open { white-space: nowrap; text-align: right; }
.data-table th.col-open { text-align: right; }
/* The flex goes on a wrapper inside the cell, never on the <td>: a table cell told to be a flex
   container leaves the row's layout and the column stops lining up with its header. */
.open-cell { display: flex; align-items: center; justify-content: flex-end; gap: 10px; }
.open-total { font-variant-numeric: tabular-nums; }

/* ---- The Sites page's filter stack ----
   The page's three axes and the count line beneath them (page.templ §filterStack). The axes are one
   .filter-row since 2026-08-07 — they were three stacked .filter-bar chip rows, which is what this
   block existed to tighten — so all that is left is to pull the count line up against them. */
.filter-stack { margin-bottom: 16px; }
.filter-stack .filter-row { margin-bottom: 8px; }
/* How much of the scope is on screen. One line for both views — the map carried its own inside the
   severity card until 2026-08-06, and two counts of one set in two corners invite being read as two
   numbers. */
.list-count { font-size: 12px; color: var(--text-secondary); margin-bottom: 4px; }

/* ---- The map/list toggle ----
   A segmented control in the page header, beside Export CSV. Links, not buttons: each view is a
   location (handler.go §ONE PAGE, TWO VIEWS). */
.view-toggle {
  display: inline-flex;
  border: 1px solid var(--border); border-radius: var(--radius-sm);
  overflow: hidden; background: white;
}
.view-toggle-btn {
  padding: 5px 14px;
  font-size: 12px; font-weight: 500;
  color: var(--text-secondary); text-decoration: none;
  background: white; white-space: nowrap;
  transition: all 0.15s;
}
.view-toggle-btn + .view-toggle-btn { border-left: 1px solid var(--border); }
.view-toggle-btn:hover { color: var(--guidant-navy); background: var(--surface-muted); }
.view-toggle-btn.active { background: var(--guidant-navy); color: white; }

/* An empty map. Says so in words, because a map box with no pins in it reads as a rendering failure
   rather than as an empty set (map.templ §mapRegion). */
.map-empty { padding: 48px 16px; text-align: center; color: var(--text-secondary); }

/* Tier B sorted headers: plain links styled as the header text. The Tier A affordance gating
   (data-sort-ready) does not apply — the server answers the href, so there is nothing to wait for. */
.data-table th a.th-sort { color: inherit; text-decoration: none; }
.data-table th a.th-sort:hover .sort-ind { opacity: 1; }

/* ---- Read-only fact list (the job workbench's own fields, which are no longer a form: a job is
   corrected by re-uploading the CSV that owns it — implementation-plan.md decision 15). ---- */
.facts { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin-bottom: 12px; }
.facts dt { font-size: 12px; color: var(--text-secondary); }
.facts dd { margin: 0; font-size: 13px; }

/* ---- The pages outside the shell (2026-08-19) ---------------------------------------------------
   Sign in, forgot password, set password, and the two dead-end pages the reset flow can land on.
   They are the only full documents in the product that do not run through internal/layout, because
   every visitor to one is by definition someone with no session — there is no sidebar to draw and no
   account to draw it for.

   Not running through the shell is why they drifted: 49 inline style attributes between the two
   files, a card at 10px radius where every other card in the product is 8, inputs bordered #ccd0d5
   where every other input is --border, labels at 12px sentence case where every other label is 11px
   uppercase, and a page ground reaching for `var(--bg-secondary, #f4f5f7)` — a custom property that
   has never existed anywhere, so what rendered was the fallback, one point off --surface.

   None of that was visible as a fault. It is visible as a page that is subtly not the same product,
   which is worse on the FIRST screen a customer ever sees than it would be anywhere else. The rules
   below are the whole presentation of those pages; the fields inside the card are .form-* and the
   error is .alert.alert-danger, exactly as they are on every authenticated form. */
.auth-body {
  display: flex; align-items: center; justify-content: center;
  min-height: 100vh;
  background: var(--surface);
  padding: 24px;
}
.auth-card {
  width: 100%; max-width: 380px;
  padding: 32px;
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-md);
}
/* The wordmark lock-up. The mark is the 155×160 raster at /static/img/logo.png drawn at 32px, and
   the words beside it are TEXT — Lato bold, tracked out a point — rather than part of the image.
   That is a standing compromise, not a design: there is no vector wordmark in the repo. */
.auth-brand { display: flex; align-items: center; gap: 10px; margin-bottom: 24px; }
.auth-mark  { height: 32px; width: 32px; }
.auth-wordmark { font-weight: 700; letter-spacing: 1px; color: var(--guidant-navy); }
.auth-tagline  { font-size: 11px; color: var(--text-secondary); }
/* The card's own heading. 16px rather than the .page-title 22px: it names a single task inside a
   380px card, and a page title's weight over one field reads as a shout. */
.auth-title { font-size: 16px; font-weight: 700; margin-bottom: 12px; }
.auth-prose { font-size: 13px; line-height: 1.6; }
/* The quiet line at the foot of the card — the fixture note, the password rule. */
.auth-note  { font-size: 11px; line-height: 1.5; color: var(--text-secondary); }
/* A card is one column, so its button fills it. Kept general rather than .auth-btn: the modals and
   the field-action screens want the same thing and had each written width:100% inline. */
.btn-block { width: 100%; justify-content: center; }
/* A link that is underlined AT REST. Used once, for the sign-in page's forgot-password line: it is
   that page's only secondary action and it sits alone under the button, where an underline-on-hover
   link reads as a line of grey text somebody has to go hunting through. */
.link-underlined { text-decoration: underline; }
