/* HydraB design tokens — forest + electric on warm-white. */
:root {
  --hb-forest: #1A3C34;
  --hb-electric: #0AC135;
  --hb-warm-white: #F7F7F0;
  --hb-pale-lime: #EEF9DB;
  --hb-mint: #BBDDCF;
  --hb-sky: #8BC3DE;
  --hb-slate: #587481;
  --hb-orange: #F98513;
  --hb-forest-ink: #102820;

  /* Default = dark theme. Cards sit slightly lighter than the forest
     page so they elevate rather than sink. */
  --bg: #1A3C34;
  --bg-deep: #102820;
  --panel: #1F4239;
  --panel-2: #244C42;
  --line: rgba(247, 247, 240, 0.14);
  --line-strong: rgba(247, 247, 240, 0.22);
  --text: #F7F7F0;
  --muted: rgba(247, 247, 240, 0.65);
  --primary: #0AC135;
  --primary-ink: #102820;
  --primary-soft: rgba(10, 193, 53, 0.12);
  --accent: #0AC135;
  --error: #FF6B6B;
  --warn: #F98513;
  --good: #0AC135;
  --info: #8BC3DE;

  /* Map pin fills. Live/online defer to --good/--warn; the two quiet states get
     their own tokens because no other part of the UI paints a "no recent fix"
     colour. Pins are flat and fully opaque, so the quiet states have to read as
     quiet by colour alone. */
  --pin-offline: #587481;
  --pin-unknown: #8aa0b3;

  --radius: 0.625rem;
  --radius-sm: calc(var(--radius) * 0.6);
  --radius-lg: calc(var(--radius) * 1.4);
  --radius-pill: 999px;
  --shadow-card: 0 1px 2px rgba(0,0,0,0.20), 0 0 0 1px rgba(247,247,240,0.04);
  --shadow-drawer: 12px 0 32px rgba(0, 0, 0, 0.30);
  --shadow-modal: 0 12px 40px rgba(0, 0, 0, 0.35);

  /* One declaration each for the two stacks the app uses; both were repeated
     verbatim at their use sites. The `.heyhy-root` block keeps its own literal
     copies — the widget parity test pins those character for character. */
  --font-body: "Inter", -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  --font-mono: ui-monospace, SFMono-Regular, Menlo, monospace;

  /* One height for every interactive control that can sit on a filter/action
     row — buttons, the multi-select trigger, selects, text inputs and inline
     checkbox labels. Before this token those five shapes rendered at 38 / 38 /
     36 / 34 / 34px purely as a by-product of their own padding, so the geofence
     action row showed three different heights side by side. Each control takes
     it as a `min-height` and centres its content, with a small padding-block
     kept only as a floor; the token, not the padding, sets the height, so a
     font-size or border change can no longer pull the controls apart one at a
     time. Not themed — geometry, not colour. */
  --control-h: 38px;

  /* The filter bar's chevron, for controls that cannot hold an inline <svg>.
     Identical geometry to the .group-multi-caret markup (polyline 6 9 / 12 15 /
     18 9, 2px round stroke) so a native <select> and a .group-multi-trigger show
     the same caret. Used as a MASK, so the stroke colour here is irrelevant and
     the visible colour comes from `currentColor` at the use site — that is what
     keeps it theme-aware. Not themed itself: geometry, not colour. */
  --caret-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");

  /* ── Nav rail geometry ──
     --rail-track is the LIVE width and the only piece of state: <body> pads
     itself by it and the rail takes it as a width, so a collapse is one
     variable change on one clock. Two independent rules (a body padding and a
     rail width) could disagree mid-animation and leave a gap or an overlap;
     one custom property cannot.
     The collapsed width is not a round number picked by eye — it is twice the
     icon's centre line (12px rail padding + 12px item padding + 10px half of a
     20px glyph = 34px), which is exactly what lands the glyph dead centre in
     the icon-only rail while the icon's x never moves during the animation.
     Change any of those three paddings and this number has to move with them. */
  --rail-w: 240px;
  --rail-w-collapsed: 68px;
  --rail-track: var(--rail-w);
  --rail-ms: 260ms;
  --ease: cubic-bezier(0.4, 0, 0.2, 1);
  /* Measured off the real header by app.js. It cannot be hardcoded: the header
     is content-sized and its filter row wraps to a second line at some widths,
     so a literal would leave the rail starting mid-header or floating below it.
     This value is only the first-frame fallback, before the observer runs. */
  --topbar-h: 62px;

  font-synthesis: none;
}

/* Collapsed is stored on <html> (set pre-paint in index.html, so the rail never
   flashes at the wrong width) rather than on <body>, which does not exist when
   that script runs. */
:root[data-rail="collapsed"] { --rail-track: var(--rail-w-collapsed); }

[data-theme="light"] {
  --bg: #F7F7F0;
  --bg-deep: #EEF9DB;
  --panel: #FFFFFF;
  --panel-2: #EEF9DB;
  --line: rgba(26, 60, 52, 0.14);
  --line-strong: rgba(26, 60, 52, 0.22);
  --text: #1A3C34;
  --muted: #587481;
  --primary: #1A3C34;
  --primary-ink: #F7F7F0;
  --primary-soft: rgba(26, 60, 52, 0.08);
  --accent: #1A3C34;
  --error: #D83232;
  --warn: #F98513;
  --good: #0AC135;
  --info: #8BC3DE;
  /* Darker than the dark theme's, for contrast against the light street basemap. */
  --pin-offline: #4a6270;
  --pin-unknown: #7c93a6;
  --shadow-card: 0 1px 2px rgba(26,60,52,0.06), 0 0 0 1px rgba(26,60,52,0.04);
  --shadow-drawer: 12px 0 32px rgba(0, 0, 0, 0.30);
  --shadow-modal: 0 12px 40px rgba(0, 0, 0, 0.35);
}

* { box-sizing: border-box; }

html, body {
  margin: 0;
  background: var(--bg);
  color: var(--text);
  font-family: var(--font-body);
  font-size: 14px;
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
}

a { color: var(--primary); text-decoration: none; }
a:hover { text-decoration: underline; }
code { font-family: var(--font-mono); font-size: 12.5px; color: var(--muted); }

/* ──────────── Topbar ──────────── */
header {
  display: flex;
  align-items: center;
  gap: 16px;
  padding: 12px 24px;
  border-bottom: 1px solid var(--line);
  /* --panel, the same fill the nav rail takes, so the topbar and the rail read
     as ONE piece of chrome rather than two adjacent surfaces. It is a flat
     opaque fill in both themes: the bar used to be a 70%-of-page frosted panel
     with a saturate+blur behind it, which by definition tinted itself with
     whatever it was over (map tiles, table rows) and so could never match the
     rail's solid fill. The blur went with it — it buys nothing behind an opaque
     colour and was costing a GPU pass on every scroll.
     Note the two fills are close by design, not by accident: --panel sits one
     step off --bg in both themes (#1F4239 on #1A3C34 dark, #FFFFFF on #F7F7F0
     light — about 3% in light). That is why the chrome's OUTER edge keeps its
     border: with contrast this low the border, not the fill, is what separates
     chrome from content. The seam removed below is the INTERNAL one. */
  background: var(--panel);
  position: sticky;
  top: 0;
  /* Above Leaflet's controls (which sit at z-index 1000) so the
     map can never overlap the sticky topbar on scroll. */
  z-index: 1500;
}

.brand {
  display: flex;
  align-items: center;
  gap: 12px;
  flex: 0 0 auto;
}
/* The anchor sits BETWEEN .brand and its children, so it has to carry
   .brand's flex layout itself — inheriting nothing, a plain block <a>
   would stack the logo above the title and reflow the topbar. */
.brand-link {
  display: flex;
  align-items: center;
  gap: 12px;
  text-decoration: none;
  color: inherit;
  cursor: pointer;
  border-radius: var(--radius-sm);
}
/* The global `a:hover { text-decoration: underline }` outranks .brand-link on
   specificity (type + pseudo-class), so the reset has to be restated here or
   the whole brand block underlines on hover. */
.brand-link:hover { text-decoration: none; }
.brand-link:focus { outline: none; }
.brand-link:focus-visible {
  outline: 2px solid var(--primary);
  outline-offset: 2px;
}
/* HydraB / Wrightbus chevron logo. Height set to match the brand title's
   cap-height so it sits visually centred next to "Uptime 365" — narrower
   than the letter badge it replaced so the topbar still reads as a
   horizontal flexbox rather than getting visually heavy on the left.
   `alt=""` and `aria-hidden="true"` on the <img> because the title text
   already announces the app to screen readers. */
.brand-logo {
  height: 28px;
  display: block;
  user-select: none;
  pointer-events: none;
}
.brand-logo-img {
  height: 28px;
  width: auto;
  display: block;
}
/* Dark is this app's DEFAULT theme (:root is dark; [data-theme="light"] is the
   override), so the reversed lockup is the default too and light overrides it.
   Getting this the wrong way round shows navy-on-forest for the one frame before
   any attribute is read — and on the default theme, forever. */
.brand-logo-on-light { display: none; }
[data-theme="light"] .brand-logo-on-dark { display: none; }
[data-theme="light"] .brand-logo-on-light { display: block; }
/* OPERATOR CO-BRAND. A second lockup after ours for fleets that have uploaded
   one (THEME_MANAGER_PLAN.md phase 4). Height matches .brand-logo-img exactly,
   so a fleet with a logo and a fleet without have the SAME header height —
   nothing below it moves. The width is free but capped: a 6:1 banner would push
   the account trigger off a laptop screen, and `object-fit: contain` letterboxes
   rather than squashing whatever shape arrives.
   `display: none` is restated for [hidden] because this rule sets `display`
   and would otherwise outrank the attribute on source order. */
.brand-cobrand {
  display: flex;
  align-items: center;
  gap: 12px;
  flex: 0 1 auto;
  min-width: 0;
}
.brand-cobrand[hidden] { display: none; }
/* A hairline, not a pipe character: it has to hold at 1px in both themes, and
   --line is the token every other seam in the chrome uses. */
.brand-cobrand-divider {
  width: 1px;
  height: 28px;
  flex: 0 0 auto;
  background: var(--line);
}
.brand-cobrand-img {
  height: 28px;
  width: auto;
  max-width: 140px;
  object-fit: contain;
  display: block;
  user-select: none;
  pointer-events: none;
}
.brand-text { display: flex; flex-direction: column; line-height: 1.1; }
.brand-text.brand-text-row {
  flex-direction: row;
  align-items: center;
  gap: 10px;
}
.brand-text .brand-title {
  font-size: 14px;
  font-weight: 600;
  letter-spacing: -0.01em;
  margin: 0;
}
/* "Internal testing" badge sits next to the brand title so the build
   provenance is always visible. Uses warn token + a tinted background
   so it reads in both themes without needing per-theme overrides. */
.brand-pill {
  display: inline-flex;
  align-items: center;
  height: 20px;
  padding: 0 10px;
  border-radius: var(--radius-pill);
  background: color-mix(in srgb, var(--warn) 18%, transparent);
  color: var(--warn);
  border: 1px solid color-mix(in srgb, var(--warn) 38%, transparent);
  font-size: 10.5px;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  white-space: nowrap;
}

/* Narrow viewports: the header flexbox doesn't wrap, so the new
   "Internal testing" pill plus the user-trigger plus the bell
   overflow horizontally on phones. Drop the pill and tighten the
   trigger width below ~640px — the dashboard already responds. */
@media (max-width: 640px) {
  .brand-pill { display: none; }
  /* The co-brand goes with the pill, and for the same reason: the header
     flexbox does not wrap, and two lockups plus the trigger plus the bell
     overflow on a phone. Ours is the one that identifies the app, so it is the
     one that stays. */
  .brand-cobrand { display: none; }
  .user-trigger { max-width: 180px; }
}

header h1 {
  font-size: 14px;
  font-weight: 600;
  margin: 0;
  letter-spacing: -0.01em;
  flex: 0 0 auto;
}

.user-menu {
  position: relative;
  margin-left: auto;
}
.user-trigger {
  background: transparent;
  border: 1px solid transparent;
  color: var(--text);
  font: inherit;
  font-size: 12.5px;
  padding: 6px 10px;
  border-radius: var(--radius-sm);
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  gap: 6px;
  transition: background 120ms ease, border-color 120ms ease;
  /* Long emails wrapping the trigger to two lines pushed the topbar
     height past the slideover's hardcoded top: 56px, leaving the
     slideover misaligned with the bottom of the topbar. Force a
     single line with ellipsis truncation; full identity stays in
     the dropdown panel below. The cap is wide enough to comfortably
     show a 30-char email without truncating into the bell + theme
     toggle on the right. */
  max-width: 320px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.user-trigger::after {
  content: "";
  width: 0;
  height: 0;
  border-left: 4px solid transparent;
  border-right: 4px solid transparent;
  border-top: 4px solid currentColor;
  opacity: 0.6;
  margin-left: 2px;
}
.user-trigger:hover { background: var(--primary-soft); border-color: var(--line); }
.user-trigger[aria-expanded="true"] { background: var(--primary-soft); border-color: var(--line-strong); }

.user-menu-panel {
  position: absolute;
  right: 0;
  top: calc(100% + 6px);
  min-width: 240px;
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  box-shadow: 0 12px 32px rgba(0,0,0,0.30), var(--shadow-card);
  padding: 10px;
  z-index: 60;
  animation: menu-in 120ms ease-out;
}
.user-menu-panel[hidden] { display: none; }
@keyframes menu-in {
  from { opacity: 0; transform: translateY(-4px); }
  to   { opacity: 1; transform: translateY(0); }
}
.user-menu-section { padding: 6px 4px 8px; }
.user-menu-label {
  font-size: 10px;
  font-weight: 600;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--muted);
  margin-bottom: 8px;
}
.user-menu-divider {
  height: 1px;
  background: var(--line);
  margin: 6px -10px;
}
.user-menu-item {
  width: 100%;
  background: transparent;
  border: 0;
  text-align: left;
  font: inherit;
  font-size: 13px;
  color: var(--text);
  padding: 8px 10px;
  border-radius: var(--radius-sm);
  cursor: pointer;
  transition: background 100ms ease;
}
.user-menu-item:hover { background: var(--primary-soft); }

/* Segmented control — used for Miles / Kilometres */
.seg {
  display: inline-flex;
  width: 100%;
  background: var(--panel-2);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  padding: 3px;
  gap: 2px;
}
.seg-opt {
  flex: 1;
  background: transparent;
  border: 0;
  font: inherit;
  font-size: 12.5px;
  font-weight: 500;
  color: var(--muted);
  padding: 6px 10px;
  border-radius: calc(var(--radius-sm) - 2px);
  cursor: pointer;
  transition: background 120ms ease, color 120ms ease;
}
.seg-opt:hover { color: var(--text); }
.seg-opt:focus { outline: none; }
.seg-opt:focus-visible {
  outline: 2px solid var(--primary);
  outline-offset: 2px;
}
.seg-opt[aria-checked="true"] {
  background: var(--primary);
  color: var(--primary-ink);
  font-weight: 600;
}

/* Colour-theme swatches (user-menu popover). Six presets, three across —
   two rows fit the panel's width without shrinking the labels below legible.
   The chip's THREE colours are set inline per swatch by renderPresetPicker()
   from THEME_PRESETS for the current mode: background = that preset's --panel,
   border = --line-strong, and `color` = --primary, which the inner square picks
   up as currentColor. Everything the swatch does NOT show a preset with (hover,
   focus, the selected ring, the label) uses the ACTIVE theme's tokens, so the
   control itself reads correctly in both light and dark. */
.preset-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  /* Equal-height cells, because one label wraps to two lines ("High contrast")
     and the rest do not — without this the selected ring and the hover fill
     come out a different height on that swatch than on its neighbours. */
  grid-auto-rows: 1fr;
  gap: 4px;
}
.preset-swatch {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 5px;
  background: transparent;
  border: 1px solid transparent;
  border-radius: var(--radius-sm);
  padding: 7px 2px 6px;
  font: inherit;
  color: var(--muted);
  cursor: pointer;
  transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
}
.preset-swatch:hover:not(:disabled) { background: var(--primary-soft); color: var(--text); }
.preset-swatch:focus { outline: none; }
.preset-swatch:focus-visible {
  outline: 2px solid var(--primary);
  outline-offset: 2px;
}
.preset-swatch[aria-checked="true"] {
  border-color: var(--primary);
  color: var(--text);
}
/* Fleet-locked. Dimmed and inert, but still legible and still SHOWN — a user
   whose colours are chosen for them should be able to see what is on offer and
   read why it is not theirs to change (.preset-locked-note). */
.preset-swatch:disabled { cursor: default; opacity: 0.45; }
.preset-chip {
  position: relative;
  display: block;
  width: 26px;
  height: 26px;
  border-radius: var(--radius-sm);
  border: 1px solid var(--line-strong);
}
.preset-chip::after {
  content: "";
  position: absolute;
  inset: 6px;
  border-radius: 2px;
  background: currentColor;
}
.preset-name {
  font-size: 10.5px;
  font-weight: 500;
  line-height: 1.15;
  text-align: center;
  /* Two lines' worth, always — see grid-auto-rows above. */
  min-height: 2.3em;
}
.preset-locked-note {
  margin: 9px 0 0;
  font-size: 11px;
  line-height: 1.35;
  color: var(--muted);
}

.icon-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  height: 32px;
  width: 32px;
  border-radius: var(--radius-sm);
  border: 1px solid var(--line);
  background: transparent;
  color: var(--text);
  cursor: pointer;
  transition: background 120ms ease, border-color 120ms ease;
}
.icon-btn:hover { background: var(--primary-soft); border-color: var(--line-strong); }
.icon-btn svg { height: 16px; width: 16px; }

/* ──────────── Notifications: bell, badge, popover ──────────── */
.notif-wrap { position: relative; display: inline-flex; align-items: center; }
.notif-bell { position: relative; }
.notif-badge {
  position: absolute;
  top: -4px;
  right: -4px;
  min-width: 16px;
  height: 16px;
  padding: 0 4px;
  border-radius: var(--radius-pill);
  background: var(--error);
  color: #fff;
  font-size: 10px;
  font-weight: 700;
  line-height: 16px;
  text-align: center;
  pointer-events: none;
  box-shadow: 0 0 0 1.5px var(--bg);
}
/* Inbox poll has failed N times in a row — the count on the badge is
   the last-known value, not current. Grey it out so the bell stops
   reading as "you definitely have N unread". */
.notif-badge.is-offline {
  background: var(--muted);
  color: var(--bg);
  opacity: 0.85;
}
.notif-popover {
  position: absolute;
  top: calc(100% + 8px);
  right: 0;
  width: 360px;
  max-width: calc(100vw - 24px);
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  box-shadow: var(--shadow-elevated, 0 8px 24px rgba(0,0,0,0.18));
  z-index: 50;
  overflow: hidden;
}
.notif-popover-head {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 10px 14px;
  font-weight: 600;
  border-bottom: 1px solid var(--line);
}
.notif-popover-list {
  list-style: none;
  margin: 0;
  padding: 0;
  max-height: 360px;
  overflow-y: auto;
}
.notif-popover-list li {
  padding: 10px 14px;
  border-bottom: 1px solid var(--line);
  cursor: pointer;
  transition: background 100ms ease;
}
.notif-popover-list li:hover { background: var(--primary-soft); }
.notif-popover-list li:last-child { border-bottom: 0; }
.notif-popover-list li.is-read { opacity: 0.55; transition: opacity 200ms ease; }
.notif-popover-list li.is-read .row-title { font-weight: 400; }
.notif-popover-list .row-title { font-weight: 500; font-size: 13px; }
.notif-popover-list .row-sub { font-size: 11px; color: var(--muted); margin-top: 2px; }
.notif-popover-foot {
  padding: 8px 14px;
  font-size: 12px;
  display: flex;
  gap: 6px;
  align-items: center;
  border-top: 1px solid var(--line);
}
.notif-popover-link { color: var(--primary); text-decoration: none; }
.notif-popover-link:hover { text-decoration: underline; }
.notif-popover-empty { padding: 20px 14px; text-align: center; font-size: 12.5px; color: var(--muted); }

/* ──────────── Inbox + subscriptions views ──────────── */
#inbox-table .inbox-when-col { width: 160px; }
#inbox-table .inbox-actions-col { width: 90px; text-align: right; }
#inbox-table tbody tr.is-unread { background: var(--primary-soft); }
#inbox-table tbody tr.is-unread .row-title { font-weight: 600; }
#inbox-table .inbox-actions { display: inline-flex; gap: 4px; justify-content: flex-end; }
#inbox-table .inbox-actions button {
  background: transparent;
  border: 0;
  color: var(--muted);
  padding: 4px 6px;
  border-radius: var(--radius-sm);
  cursor: pointer;
  font-size: 11px;
  font-weight: 600;
}
#inbox-table .inbox-actions button:hover { background: var(--line); color: var(--text); }
#inbox-table .inbox-actions button.danger:hover { color: var(--error); }
.inbox-empty, .subs-empty { padding: 24px; text-align: center; }

.subs-sections {
  display: flex;
  flex-direction: column;
  gap: 24px;
}
.subs-section {
  background: var(--bg-deep, var(--panel));
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 16px;
}
.subs-section-head {
  display: grid;
  grid-template-columns: auto 1fr auto;
  gap: 14px;
  align-items: center;
  margin-bottom: 12px;
}
.subs-section-icon {
  width: 36px;
  height: 36px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  background: var(--primary-soft);
  color: var(--primary);
  border-radius: var(--radius-sm);
}
.subs-section-icon svg { display: block; }
.subs-section-title { font-size: 15px; font-weight: 600; color: var(--text); }
.subs-section-desc { font-size: 12.5px; margin-top: 2px; }
.subs-section-actions {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  font-size: 12px;
}
.subs-section-empty {
  padding: 12px 14px;
  font-size: 12.5px;
  list-style: none;
}
.subs-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.subs-list li {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 12px 14px;
  display: grid;
  grid-template-columns: 1fr auto;
  gap: 8px;
  align-items: center;
}
.subs-list .sub-name { font-weight: 600; font-size: 13px; }
.subs-list .sub-summary { font-size: 12px; color: var(--muted); margin-top: 2px; }
.subs-list .sub-actions { display: flex; gap: 4px; }
.subs-list .sub-actions button {
  background: transparent;
  border: 0;
  color: var(--muted);
  padding: 4px 8px;
  border-radius: var(--radius-sm);
  cursor: pointer;
  font-size: 11px;
  font-weight: 600;
}
.subs-list .sub-actions button:hover { background: var(--line); color: var(--text); }
.subs-list .sub-actions button.danger:hover { color: var(--error); }
.subs-list li.is-disabled { opacity: 0.55; }
/* Row currently in the inline editor — hidden so the same subscription
   doesn't appear twice (once being edited, once in the list with
   another Edit button). Restored on Cancel/Save. */
.subs-list li.is-editing { display: none; }

/* ─── Inline subscription editor ───────────────────────────
   Lives inside each .subs-section between the section header and the
   list of existing rows. Animated open/close via max-height. Hosts
   the per-kind form fields in [data-slot="kind-fields"]. */
/* Animate open/close via grid-template-rows 0fr→1fr rather than a fixed
   max-height cap: 1fr resolves to the form's NATURAL height, so a tall form
   (e.g. 50 recipients) can never push Save/Cancel past a cap where overflow
   clips them — while the slide animation is preserved. */
.subs-section-editor-slot {
  display: grid;
  grid-template-rows: 0fr;
  overflow: hidden;
  transition: grid-template-rows 320ms ease;
}
.subs-section-editor-slot.is-open {
  grid-template-rows: 1fr;
}
.subs-inline-editor {
  min-height: 0; /* let the grid item collapse to 0 while closed */
  background: var(--panel);
  border: 1px solid var(--line-strong);
  border-radius: var(--radius);
  margin: 12px;
  box-shadow: var(--shadow-card);
  animation: subs-inline-in 220ms ease-out;
}
@keyframes subs-inline-in {
  from { opacity: 0; transform: translateY(-4px); }
  to   { opacity: 1; transform: translateY(0); }
}
.subs-inline-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 12px 16px;
  border-bottom: 1px solid var(--line);
}
.subs-inline-title {
  margin: 0;
  font-size: 14px;
  font-weight: 600;
  letter-spacing: -0.01em;
  color: var(--text);
}
.subs-inline-close {
  background: transparent;
  border: 0;
  color: var(--muted);
  font-size: 22px;
  line-height: 1;
  padding: 2px 8px;
  border-radius: var(--radius-sm);
  cursor: pointer;
}
.subs-inline-close:hover { background: var(--primary-soft); color: var(--text); }
.subs-inline-body {
  padding: 14px 16px 4px;
  display: flex;
  flex-direction: column;
  gap: 14px;
}
.subs-inline-name-row { display: flex; flex-direction: column; gap: 4px; }
.subs-inline-name-label {
  font-size: 10.5px;
  font-weight: 600;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--muted);
}
.subs-inline-name {
  background: var(--panel-2);
  color: var(--text);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  padding: 8px 10px;
  font: inherit;
  font-size: 13px;
}
.subs-inline-name:focus {
  outline: none;
  border-color: var(--primary);
  box-shadow: 0 0 0 3px var(--primary-soft);
}
.subs-inline-error {
  color: var(--error);
  font-size: 12.5px;
  background: color-mix(in srgb, var(--error) 10%, transparent);
  border: 1px solid color-mix(in srgb, var(--error) 30%, transparent);
  border-radius: var(--radius-sm);
  padding: 8px 10px;
}
.subs-inline-actions {
  display: flex;
  justify-content: flex-end;
  gap: 8px;
  padding: 12px 0 16px;
  border-top: 1px solid var(--line);
  margin-top: 6px;
}

/* ─── Stationary-report kind fields (rendered inside the inline shell) ─── */
.sr-edit {
  display: flex;
  flex-direction: column;
  gap: 14px;
}
.sr-field {
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 10px 14px;
  margin: 0;
  display: flex;
  flex-direction: column;
  gap: 10px;
}
.sr-field > legend {
  padding: 0 6px;
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--muted);
}
.sr-help { margin: 0; font-size: 12px; line-height: 1.4; }
.sr-label {
  font-size: 10.5px;
  font-weight: 600;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--muted);
}
.sr-row { display: flex; flex-direction: column; gap: 4px; }
.sr-row-two {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 12px;
}
@media (max-width: 540px) {
  .sr-row-two { grid-template-columns: 1fr; }
}
.sr-cell { display: flex; flex-direction: column; gap: 4px; }
.sr-suffix {
  display: flex;
  align-items: stretch;
  gap: 0;
}
.sr-suffix input[type="number"] {
  flex: 1 1 auto;
  border-top-right-radius: 0;
  border-bottom-right-radius: 0;
  min-width: 0;
}
.sr-suffix-unit {
  display: inline-flex;
  align-items: center;
  padding: 0 10px;
  background: var(--panel-2);
  border: 1px solid var(--line);
  border-left: 0;
  border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
  color: var(--muted);
  font-size: 12.5px;
}
.sr-edit input[type="text"],
.sr-edit input[type="email"],
.sr-edit input[type="number"],
.sr-edit select {
  background: var(--panel-2);
  color: var(--text);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  padding: 8px 10px;
  font: inherit;
  font-size: 13px;
}
.sr-edit input:focus,
.sr-edit select:focus {
  outline: none;
  border-color: var(--primary);
  box-shadow: 0 0 0 3px var(--primary-soft);
}

/* Fleet checkbox grid */
.sr-fleet-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
  gap: 4px;
  max-height: 200px;
  overflow-y: auto;
  background: var(--panel-2);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  padding: 4px;
}
.sr-fleet-row {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px 8px;
  border-radius: var(--radius-sm);
  cursor: pointer;
  font-size: 13px;
  user-select: none;
}
.sr-fleet-row:hover { background: var(--primary-soft); }
.sr-fleet-row input[type="checkbox"] {
  width: 16px;
  height: 16px;
  accent-color: var(--primary);
  margin: 0;
}

/* Hours + days chips (reuses .rs-chip from the existing modal CSS).
   The grid changes to wider columns since "9 am" is wider than "9a". */
.sr-hours-grid {
  display: grid;
  grid-template-columns: repeat(6, 1fr);
  gap: 4px;
}
@media (max-width: 540px) {
  .sr-hours-grid { grid-template-columns: repeat(4, 1fr); }
}
.sr-days-grid {
  display: grid;
  grid-template-columns: repeat(7, 1fr);
  gap: 4px;
}
.rs-chip {
  background: var(--panel-2);
  border: 1px solid var(--line);
  color: var(--text);
  font: inherit;
  font-size: 12px;
  padding: 6px 4px;
  border-radius: var(--radius-sm);
  cursor: pointer;
  text-align: center;
  transition: background 100ms ease, border-color 100ms ease, color 100ms ease;
}
.rs-chip:hover { background: var(--primary-soft); border-color: var(--primary); }
.rs-chip[aria-pressed="true"] {
  background: var(--primary);
  color: var(--primary-ink);
  border-color: var(--primary);
  font-weight: 600;
}
.rs-chip:focus-visible {
  outline: 2px solid var(--primary);
  outline-offset: 2px;
}

/* Columns picker — same .col-row pattern as the CSV export modal */
.sr-columns {
  display: flex;
  flex-direction: column;
  gap: 2px;
  max-height: 220px;
  overflow-y: auto;
  background: var(--panel-2);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  padding: 4px;
}
.sr-columns .col-row {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px 8px;
  border-radius: var(--radius-sm);
  cursor: pointer;
  font-size: 13px;
  user-select: none;
}
.sr-columns .col-row:hover { background: var(--primary-soft); }
.sr-columns .col-row input[type="checkbox"] {
  width: 16px;
  height: 16px;
  accent-color: var(--primary);
  margin: 0;
}
.sr-columns-toolbar {
  display: flex;
  gap: 6px;
  justify-content: flex-end;
}

/* Recipients */
.sr-recipients {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 4px;
  max-height: 160px;
  overflow-y: auto;
}
.sr-recipients li {
  display: flex;
  align-items: center;
  justify-content: space-between;
  background: var(--panel-2);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  padding: 6px 10px;
  font-size: 13px;
}
.sr-recipients-empty {
  background: transparent !important;
  border: 1px dashed var(--line) !important;
}
.sr-remove {
  background: transparent;
  border: 0;
  color: var(--muted);
  cursor: pointer;
  font-size: 18px;
  line-height: 1;
  padding: 0 4px;
}
.sr-remove:hover { color: var(--error); }
.sr-add-recipient {
  display: flex;
  gap: 6px;
  align-items: stretch;
}
.sr-add-recipient input { flex: 1; }
.sr-test-row {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 13px;
  color: var(--text);
  padding-bottom: 4px;
}
.sr-test-row input[type="checkbox"] {
  width: 16px;
  height: 16px;
  accent-color: var(--primary);
  margin: 0;
}

/* The base .seg-opt styles already paint the active state via the
   aria-checked attribute (our JS toggles it). Don't redefine here. */
.sub-edit-help { font-size: 11.5px; line-height: 1.4; padding-top: 0; padding-bottom: 6px; }
.sub-edit-picker {
  width: 100%;
  box-sizing: border-box;
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  /* Pre-resp 600px viewport this leaves comfortable room for ~14 rows;
     scrolls past that. clamp() lets it shrink on short screens. */
  max-height: clamp(220px, 50vh, 540px);
  overflow-y: auto;
  background: var(--panel);
}
.sub-edit-picker label {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px 10px;
  font-size: 12.5px;
  cursor: pointer;
}
.sub-edit-picker label:hover { background: var(--primary-soft); }

.search-wrap {
  position: relative;
  flex: 1 1 320px;
  min-width: 220px;
  max-width: 420px;
}
/* A control on .dashboard-controls, beside the operator select, the
   Opportunity picker and the VOR toggle — so it takes --control-h like they
   do. .search-wrap needs nothing: #bus-search is its only in-flow child
   (.search-results is absolutely positioned), so the wrapper's height tracks
   the input's and the results dropdown's `top: calc(100% + 6px)` still hangs
   off the bottom of the field. */
#bus-search {
  width: 100%;
  background: var(--panel);
  color: var(--text);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  padding: 4px 11px;
  min-height: var(--control-h);
  font: inherit;
  font-size: 13px;
  transition: border-color 120ms ease, box-shadow 120ms ease;
}
#bus-search::placeholder { color: var(--muted); }
#bus-search:focus {
  outline: none;
  border-color: var(--primary);
  box-shadow: 0 0 0 3px var(--primary-soft);
}
.search-results {
  position: absolute;
  top: calc(100% + 6px);
  left: 0;
  right: 0;
  max-height: 360px;
  overflow-y: auto;
  background: var(--panel);
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-sm);
  box-shadow: 0 12px 32px rgba(0,0,0,0.30);
  /* Sits above the topbar (z-index: 1500) so the dropdown isn't
     clipped by the sticky header's blur. */
  z-index: 1600;
  padding: 4px;
}
.search-result {
  display: grid;
  grid-template-columns: 1fr auto;
  grid-template-areas:
    "name reg"
    "job  job"
    "vin  vin";
  column-gap: 12px;
  row-gap: 2px;
  width: 100%;
  padding: 8px 10px;
  background: transparent;
  border: 0;
  border-radius: var(--radius-sm);
  text-align: left;
  font: inherit;
  cursor: pointer;
  color: var(--text);
}
.search-result:hover, .search-result[aria-selected="true"] {
  background: var(--primary-soft);
}
.search-result .sr-name { grid-area: name; font-weight: 500; }
.search-result .sr-reg  { grid-area: reg;  color: var(--muted); font-size: 12px; }
.search-result .sr-job  { grid-area: job;  color: var(--muted); font-size: 11px; }
.search-result .sr-vin  {
  grid-area: vin;
  color: var(--muted);
  font-size: 11px;
  font-family: var(--font-mono);
}
.search-result mark {
  background: color-mix(in srgb, var(--primary) 25%, transparent);
  color: inherit;
  border-radius: 2px;
  padding: 0 1px;
}
.search-empty {
  padding: 10px;
  color: var(--muted);
  font-size: 12.5px;
  text-align: center;
}

.filter-bar {
  display: flex;
  align-items: center;
  gap: 10px;
  flex: 0 1 auto;
}
.filter-bar label {
  color: var(--muted);
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: 0.12em;
  font-weight: 600;
}
/* Operator and Opportunity are now the SAME control — .group-multi, driven by
   createSearchableSelect — so neither needs a skin of its own here. The native
   <select> this replaced had a hand-built lookalike box (appearance:none, a
   sibling chevron, .select-wrap): the closed states did match, but opening one
   raised the OS menu next to a themed panel, and the operator list had no
   search. `.operator-filter` / `.opp-filter` in the markup are naming hooks
   only — they carry no rules. */

/* ──────────── Layout ──────────── */
main, section#login-view {
  padding: 24px;
  max-width: 1280px;
  margin: 0 auto;
}

/* ──────────── Login ──────────── */
#login-box[hidden] { display: none; }
#login-box {
  max-width: 420px;
  margin: 60px auto;
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--radius-lg);
  padding: 28px;
  display: grid;
  gap: 16px;
  box-shadow: var(--shadow-card);
  text-align: center;
}
#login-box h2 {
  margin: 0;
  font-size: 18px;
  font-weight: 600;
  letter-spacing: -0.01em;
}
#login-box .hint {
  margin: 0;
  color: var(--muted);
  font-size: 13px;
}
#login-button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 12px;
  background: var(--text);
  color: var(--panel);
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-sm);
  padding: 11px 16px;
  font: inherit;
  font-weight: 600;
  font-size: 14px;
  text-decoration: none;
  cursor: pointer;
  transition: filter 120ms ease, transform 120ms ease;
}
#login-button:hover { filter: brightness(1.08); text-decoration: none; }
#login-button:active { transform: translateY(1px); }
#login-button svg { flex: 0 0 auto; }
.error { color: var(--error); font-size: 12.5px; min-height: 1em; }

/* ──────────── Dashboard controls (search + group filter) ──────────── */
.dashboard-controls {
  display: flex;
  align-items: center;
  gap: 14px;
  flex-wrap: wrap;
  margin-bottom: 20px; /* overridden to 0 in the dashboard shell — the grid gap spaces it there */
  /* No margin-left: auto on .filter-bar here. Below roughly 1250px
     viewport width (depends on the rendered size of the labels and the
     Operator/Opportunity controls) the filter bar wraps to its own row —
     that's where margin-left: auto used to pin it to the right edge
     while the search box stayed at the left, which is the misalignment
     this fixes. Above that width the two share one row, where plain
     left-alignment just places the filter bar after the search box
     instead of pinning it to the right edge. */
}

/* ──────────── Top stat cards ──────────── */
.topcards {
  display: grid;
  /* 180px min — keeps the cards on one row inside main's 1280px max-width
     (1232px after padding). Wraps gracefully on narrower viewports thanks
     to auto-fit. This is the sub-1101px / non-dashboard shape; the wide
     dashboard shell re-declares this as a single scrolling column. */
  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
  gap: 14px;
  margin-bottom: 28px;
}

/* ──────────── Dashboard shell (full-viewport map layout) ────────────
   The dashboard — and only the dashboard — drops the centred content box and
   becomes a viewport-height frame: filters across the top, KPI cards down a
   fixed left column, the Live map filling the rest to the bottom edge with no
   page scroll. Every other view keeps main's max-width box, so all of this is
   gated on body.view-dashboard (set/cleared in renderCurrentView(), cleared in
   showLogin()).

   Gated at ≥1101px on purpose: below that the cards + map can't both fit a
   viewport height, so tablets and small laptops keep today's scrolling layout
   verbatim — zero regression risk on the narrow end.

   Also gated on ≥640px TALL. The shell turns page scrolling off, so a short
   window has no way to recover the height it can't show: fixed overhead is
   ~146px (header + view padding + the controls row + the grid gap), which at
   640px still leaves the map ~494px — about the 520px it gets elsewhere. Below
   that floor the same stacked, scrolling fallback applies as on narrow screens.

   The header height is never hardcoded: it's sticky and content-sized (the
   filter bar wraps at some widths), so the body is a flex column and the view
   takes flex: 1 1 auto instead of a calc(100dvh - Npx).

   INVARIANT: any future in-flow child added directly to <body> would take a
   share of that flex height away from the dashboard. Slideovers/overlays are
   fine (they're positioned out of flow); new in-flow siblings are not. */
@media (min-width: 1101px) and (min-height: 640px) {
  body.view-dashboard { height: 100dvh; overflow: hidden; display: flex; flex-direction: column; }
  body.view-dashboard > header { flex: 0 0 auto; }
  body.view-dashboard #dashboard-view {
    flex: 1 1 auto; min-height: 0; max-width: none; width: 100%; margin: 0;
    padding: 16px 20px 20px;
    display: grid;
    /* Controls span both columns here. They move into the map column only at
       >=1440px — see the block after this one, which explains why. */
    grid-template-areas:
      "controls controls"
      "cards    map";
    grid-template-columns: 320px minmax(0, 1fr);
    grid-template-rows: auto minmax(0, 1fr);
    gap: 14px 16px;
    overflow: hidden; /* the cards column scrolls internally; the page never does */
  }
  /* An author `display` beats the UA stylesheet's [hidden] { display: none },
     so the grid above would keep the dashboard painted on every other view.
     Same trap that bit the fleet detail before (see .fleet-detail) — re-state
     it explicitly rather than relying on source order. */
  body.view-dashboard #dashboard-view[hidden] { display: none; }

  body.view-dashboard .dashboard-controls { grid-area: controls; margin-bottom: 0; }
  body.view-dashboard .topcards {
    grid-area: cards;
    grid-template-columns: 1fr; /* one card per row in the 320px rail */
    align-content: start;       /* cards keep their natural height, no stretch */
    margin-bottom: 0;
    min-height: 0;
    overflow-y: auto;           /* long card stacks scroll here, not the page */
  }
  body.view-dashboard .dashboard-map {
    grid-area: map;
    display: flex;
    flex-direction: column;
    min-height: 0; /* without this the flex item floors at its content height and overflows the row */
    margin-bottom: 0; /* cancels the global `section { margin-bottom: 28px }` — the grid gap owns spacing here */
  }

  /* Take the height from the grid track instead of the fixed 520px below. The
     fullscreen rules (#map.is-expanded, position: fixed; inset: 0) are unaffected
     — fixed positioning escapes the grid, and there's no transformed ancestor
     to trap it. */
  body.view-dashboard #map { height: auto; flex: 1 1 auto; min-height: 0; }
}

/* ──────────── Dashboard: filters indented to the map's left edge (≥1440px) ────
   The search box starts on the map's left edge rather than the cards', so the
   filters read as belonging to the map they filter. Expressed as an empty first
   grid cell rather than a padding on .dashboard-controls, so the indent is the
   320px card track + the 16px gap BY CONSTRUCTION and cannot drift if either
   changes.

   Why 1440 and not the shell's own 1101: confining this row to the map column
   costs it the 336px the cards track and gap occupy, and the row has a hard
   floor. Measured, the filter bar's natural width is 657px and it cannot
   meaningfully shrink (two uppercase labels + two dropdowns). The map column is
   `viewport − 616`, so:
     · below ~1273px the filter bar is WIDER than the column. body.view-dashboard
       sets overflow: hidden, so it would not scroll — the Opportunity picker
       would simply be clipped off the right-hand side.
     · below ~1437px it fits but cannot share a line with the search, so the row
       wraps to two and gives ~52px of height straight back off the map this
       whole layout exists to make taller — leaving the map SHORTER than before
       the change.
   1440px is the first round width where the indent costs neither: the column is
   824px and 657 + 14 gap + 153 search fits on one line. Below it the row spans
   both columns exactly as it always did. Nothing gets worse at any width.

   Not gated on the rail being collapsed, though that would widen the column by
   172px and let the indent start ~170px earlier. Deliberately left simple:
   correctness at every width first. */
@media (min-width: 1440px) and (min-height: 640px) {
  body.view-dashboard #dashboard-view {
    /* The cards column spans BOTH rows, so the first card's top edge is level
       with the top of the search box rather than starting below the controls row.
       That empty first cell was a visible hole above the Fleet buses card once the
       controls moved into the map column, and the cards gain the row's height
       (~52px) of scrollable column.
       Note this deliberately gives up the map-top == card-top alignment that
       floating the map's old status bar bought: the map still starts below the
       controls, the cards no longer do. The map keeps every pixel it gained — nothing moved on
       the right — it is only the left column that grew upwards.
       Safe for the `auto` first row: .topcards carries `min-height: 0` and
       `overflow-y: auto` in this shell, so its min-content contribution is 0 and
       spanning it here cannot make the controls row grow and push the map down.
       Verified: controls row stays 38px and the map's top does not move. */
    grid-template-areas:
      "cards controls"
      "cards map";
  }
  /* It is the flex BASIS that has to come down, not the min-width. With
     `flex-wrap: wrap`, line breaking is decided on items' base sizes BEFORE any
     shrinking — flex-shrink only redistributes within a line already formed. So
     against a 320px basis the row wrapped and the search then sat at its 420px
     max on its own line; lowering min-width alone changed nothing (measured:
     still wrapped, still 420px). flex-grow is kept, so this is a floor and not a
     width — at 1920px the search grows back to its 420px max exactly as before.
     The trade at 1440px is a 153px search box (~18 characters, placeholder
     truncated) in exchange for 52px of map height. If the search feels too tight,
     the roomier fix is dropping the two uppercase OPERATOR/OPPORTUNITY labels
     (~185px; the dropdowns already read "All operators" / "All opportunities"),
     which hands the search ~338px back. Not done here — it was not asked for. */
  body.view-dashboard .search-wrap { flex-basis: 150px; min-width: 150px; }
}

.card {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 14px 16px;
  box-shadow: var(--shadow-card);
  transition: border-color 120ms ease, transform 120ms ease;
}
.card:hover { border-color: var(--line-strong); }
.card h3 {
  margin: 0 0 6px;
  font-size: 10.5px;
  text-transform: uppercase;
  letter-spacing: 0.12em;
  color: var(--muted);
  font-weight: 600;
  display: flex;
  align-items: center;
  gap: 8px;
}

.dot-indicator {
  display: inline-block;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  flex: 0 0 auto;
}
.dot-indicator.dot-live {
  background: var(--good);
  box-shadow: 0 0 0 0 rgba(10, 193, 53, 0.7);
  animation: pulse 1.6s infinite;
}
.dot-indicator.dot-online { background: var(--warn); }
/* Same token ui/map-markers.js reads for the offline pins, and the one the
   map's deleted colour key used, so the card cannot drift from the map. */
.dot-indicator.dot-offline { background: var(--pin-offline); }
.dot-indicator.dot-total { background: var(--info); }

/* Circled "i" beside each Fleet-card label: says the row has an explanation
   without being a control (the card is a <button>). Tokens only — --muted and
   currentColor — so it follows both themes with no per-theme override, and
   `line-height: 1` is what keeps a 12px circle round across fonts. */
.fs-info {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: 0 0 auto;
  width: 12px;
  height: 12px;
  border: 1px solid currentColor;
  border-radius: 50%;
  color: var(--muted);
  opacity: 0.75;
  font-size: 8.5px;
  font-weight: 700;
  font-style: italic;
  line-height: 1;
  font-family: Georgia, "Times New Roman", serif;
}

/* Fleet summary card — clickable, opens the slideover with the
   per-fuel breakdown. */
.card-fleet {
  appearance: none;
  font: inherit;
  color: inherit;
  text-align: left;
  cursor: pointer;
  display: flex;
  flex-direction: column;
  gap: 10px;
}
/* One hit target for the card: every click, wherever it lands, is the button's.
   Measured (headless Chrome, elementFromPoint down and across the card): the
   cursor is `pointer` over every pixel of the card and the hit element is always
   #card-fleet — so this rule is NOT the source of any cursor change. What it did
   cost was the rows' native `title`: with hit-testing landing on the button,
   Chrome walks up from the button and finds no title, so the ⓘ rows never showed
   one. .fs-item takes pointer-events back below purely so it can be HOVERED; a
   click on it still bubbles to the button and still opens the breakdown. */
.card-fleet > * { pointer-events: none; }
.card-fleet:hover { border-color: var(--line-strong); }
.card-fleet:focus-visible {
  outline: none;
  border-color: var(--primary);
  box-shadow: 0 0 0 3px var(--primary-soft);
}
.card-fleet-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
}
.card-fleet-head h3 { margin: 0; }
.card-fleet-hint {
  font-size: 16px;
  color: var(--muted);
  line-height: 1;
  transition: transform 160ms ease, color 120ms ease;
}
.card-fleet:hover .card-fleet-hint {
  color: var(--text);
  transform: translateX(2px);
}
.card-fleet[aria-expanded="true"] .card-fleet-hint {
  transform: rotate(90deg);
  color: var(--text);
}

.fleet-summary {
  display: flex;
  flex-direction: column;
  gap: 2px;
}
.fs-item {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 2px 0;
  min-width: 0;
  /* Re-enabled against the inherited `none` above so the row is a hover target
     for initFleetSummaryTips. pointer-events is decided per element, not vetoed
     by an ancestor, so this is all it takes. `cursor` is left alone: it inherits
     `pointer` from the card, and the whole card must read as one control. */
  pointer-events: auto;
}
.fs-item-head {
  display: flex;
  align-items: center;
  gap: 6px;
  flex: 1 1 auto;
  min-width: 0;
}
.fs-label {
  color: var(--muted);
  font-size: 10.5px;
  font-weight: 600;
  letter-spacing: 0.12em;
  text-transform: uppercase;
}
/* Stale-feed warning under the three buckets. Uses the same warn token +
   tinted background as .brand-pill so it reads in both themes without
   per-theme overrides. Hidden entirely when the feed is current, so the card
   looks exactly as before in normal operation. */
.fleet-feed-lag {
  display: block;
  margin: 6px 0 0;
  padding: 4px 8px;
  border-radius: var(--radius-sm);
  background: color-mix(in srgb, var(--warn) 14%, transparent);
  color: var(--warn);
  border: 1px solid color-mix(in srgb, var(--warn) 34%, transparent);
  font-size: 11px;
  font-weight: 600;
  line-height: 1.35;
  text-align: left;
}
/* Needed because the `display: block` above outranks the UA `[hidden]` rule —
   without this the note would never hide. Same pattern as .geofence-status. */
.fleet-feed-lag[hidden] { display: none; }
/* Freshness stamp under the buckets — the only "updated HH:MM:SS" on the
   dashboard since the map's status line went. `:empty` collapses it so the
   card keeps its old height until the first successful poll. */
.fleet-updated {
  display: block;
  margin-top: 6px;
  color: var(--muted);
  text-align: left;
}
.fleet-updated:empty { display: none; }
/* Hover explanation for a fleet-summary row. One element, reused by all four
   rows (initFleetSummaryTips), appended to <body> so nothing about the card's
   overflow or stacking can clip it. Same panel/line/shadow recipe as the map's
   .leaflet-tooltip, which is the app's other floating explanation, so the two
   look like one thing. pointer-events: none is load-bearing — the tooltip is
   positioned over the card it describes, and swallowing a click there would
   break the card's one job. */
.fs-tip {
  position: fixed;
  z-index: 1200;
  max-width: 260px;
  padding: 6px 8px;
  border-radius: var(--radius-sm);
  background: var(--panel);
  color: var(--text);
  border: 1px solid var(--line);
  box-shadow: var(--shadow-card);
  font-size: 11.5px;
  font-weight: 400;
  line-height: 1.45;
  letter-spacing: normal;
  text-transform: none;
  text-align: left;
  pointer-events: none;
  /* visibility, not display: an opacity transition needs both frames to be
     rendered, and `visibility` is one of the few properties that still
     transitions discretely alongside it. Hidden it is also out of the
     accessibility tree, so a screen reader never meets a stray floating
     sentence. */
  opacity: 0;
  visibility: hidden;
  transition: opacity 90ms ease, visibility 0s linear 90ms;
}
.fs-tip.is-visible {
  opacity: 1;
  visibility: visible;
  transition: opacity 90ms ease, visibility 0s;
}

.fs-value {
  font-size: 18px;
  font-weight: 600;
  letter-spacing: -0.01em;
  font-variant-numeric: tabular-nums;
  color: var(--text);
  line-height: 1.15;
  text-align: right;
  flex: 0 0 auto;
}

/* Fleet-detail slideover — reuses .bus-detail's slide-in chrome. */
.fleet-detail .bd-body {
  display: flex;
  flex-direction: column;
  gap: 18px;
  padding: 18px;
}
.fd-section {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 14px;
}
.fd-section-head {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 12px;
  padding-bottom: 10px;
  border-bottom: 1px solid var(--line);
}
.fd-section-title {
  font-size: 14px;
  font-weight: 600;
  letter-spacing: -0.01em;
  color: var(--text);
}
.fd-section-sub {
  margin-left: auto;
  color: var(--muted);
  font-size: 11.5px;
}
.fd-grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 8px;
}
.fd-cell {
  background: var(--panel-2);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  padding: 14px 12px;
  text-align: center;
}
.fd-num {
  font-size: 22px;
  font-weight: 700;
  letter-spacing: -0.01em;
  font-variant-numeric: tabular-nums;
  line-height: 1.1;
  color: var(--text);
}
.fd-lbl {
  margin-top: 4px;
  font-size: 10.5px;
  font-weight: 600;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--muted);
}
.card .big {
  font-size: 22px;
  font-weight: 600;
  line-height: 1.15;
  letter-spacing: -0.01em;
  font-variant-numeric: tabular-nums;
}
.card .small {
  color: var(--muted);
  font-size: 11.5px;
  margin-top: 4px;
}
.card .small:empty { display: none; }

/* ──────────── Section headers ──────────── */
section { margin-bottom: 28px; }
section h2 {
  font-size: 11px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.12em;
  color: var(--muted);
  margin: 0 0 12px;
}
.row-header {
  display: flex;
  align-items: center;
  gap: 12px;
  margin-bottom: 12px;
}
.row-header h2 { margin: 0; }
.row-header button {
  background: transparent;
  color: var(--text);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  padding: 6px 11px;
  cursor: pointer;
  font: inherit;
  font-size: 12px;
  font-weight: 500;
  transition: background 120ms ease, border-color 120ms ease;
}
.row-header button:hover {
  background: var(--primary-soft);
  border-color: var(--line-strong);
}
.muted { color: var(--muted); font-size: 12.5px; }


/* ──────────── Map ──────────── */
#map {
  height: 520px;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--panel);
  overflow: hidden;
  box-shadow: var(--shadow-card);
  /* Contain Leaflet's z-indices (controls go up to 1000) inside this
     stacking context so they can't paint over the topbar / slideovers. */
  position: relative;
  isolation: isolate;
  transition: filter 200ms ease;
}
/* Last refreshFleet() sweep was incomplete — desaturate slightly + dim
   the border so the markers stop reading as a live snapshot. This is the
   ONLY stale signal the map carries: the status line that used to spell out
   the reason has been removed, and the reason is a console warning. */
#map.is-stale {
  filter: saturate(0.5) brightness(0.95);
  border-color: var(--warn);
}

/* Fullscreen toggle. Sits inside #map (the element that goes fullscreen) at
   1001 — one above Leaflet's top control z-index, and contained by #map's
   isolation: isolate so it still can't paint over the topbar. */
.map-fullscreen-btn {
  position: absolute;
  top: 10px;
  right: 10px;
  z-index: 1001;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  height: 32px;
  width: 32px;
  padding: 0;
  border-radius: var(--radius-sm);
  border: 1px solid var(--line-strong);
  background: color-mix(in srgb, var(--panel) 88%, transparent);
  color: var(--text);
  cursor: pointer;
  transition: background 120ms ease, border-color 120ms ease;
}
.map-fullscreen-btn:hover { background: var(--panel); border-color: var(--primary); }
.map-fullscreen-btn:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
.map-fullscreen-btn svg { height: 16px; width: 16px; display: block; }
/* One icon at a time — expand when collapsed, compress when expanded. */
.map-fullscreen-btn .map-fs-compress,
.map-fullscreen-btn[aria-pressed="true"] .map-fs-expand { display: none; }
.map-fullscreen-btn[aria-pressed="true"] .map-fs-compress { display: block; }

/* Street-map ⇄ satellite toggle. Built in JS (mountBasemapToggle) and
   appended inside the Leaflet container, so it shares the fullscreen
   button's stacking rules: 1001 clears Leaflet's controls (max 1000) while
   the container's isolation: isolate keeps it below the topbar.

   Same 32px square, same border/fill, same 10px inset as .map-fullscreen-btn,
   down the same right edge at the other end of it — so on the dashboard (the
   only map carrying both; the geofence and vehicle maps have no fullscreen
   button) the two controls read as a matched pair rather than a stack of
   different shapes. Square means icon-only: the words moved to the tooltip +
   aria-label, and attachBasemap() moves Leaflet's attribution credit to the
   bottom-LEFT so this corner is free. */
.map-basemap-btn {
  position: absolute;
  /* Stacked directly under the fullscreen button on the map's TOP-right edge,
     which is what the comment above always described ("the other end of the same
     right edge"). It used to sit bottom-right; the HeyHy! launcher owns that
     corner now, and the first attempt at coexistence — raising this button clear
     of the launcher — put a second `cursor: pointer` control squarely in the
     approach path to it. A trace of a real session showed the result: sweeping
     towards the bubble crossed map(grab) → this button(pointer) → gutter(auto) →
     launcher(pointer), flipping the cursor three times in ~60px. Keeping the
     bottom-right corner to a single control is what stops that.
     10px + 32px button + 10px gap = 52px. */
  top: 52px;
  right: 10px;
  z-index: 1001;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  height: 32px;
  width: 32px;
  padding: 0;
  border-radius: var(--radius-sm);
  border: 1px solid var(--line-strong);
  background: color-mix(in srgb, var(--panel) 88%, transparent);
  color: var(--text);
  cursor: pointer;
  transition: background 120ms ease, border-color 120ms ease;
}
.map-basemap-btn:hover { background: var(--panel); border-color: var(--primary); }
.map-basemap-btn:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
/* A hair lighter than the fullscreen button's stroke: the globe's equator and
   meridian cross in the middle of a 16px box, and at the shared 2px weight the
   crossing fills in and reads as a blob. */
.map-basemap-btn svg { height: 16px; width: 16px; display: block; stroke-width: 1.6; }
/* One icon at a time. aria-pressed is "satellite is on", so pressed shows the
   folded map (where the next press goes) and unpressed shows the globe. */
.map-basemap-btn .map-base-street,
.map-basemap-btn[aria-pressed="true"] .map-base-satellite { display: none; }
.map-basemap-btn[aria-pressed="true"] .map-base-street { display: block; }
/* Geofence editor sits in a 12px-inset wrapper; match its other edges. It has no
   fullscreen button, so the top slot is free — no 42px offset needed. */
#geofence-map .map-basemap-btn { top: 12px; right: 12px; }

/* Native fullscreen: the map keeps #map's rules but must fill the surface. */
#map:fullscreen {
  height: 100%;
  width: 100%;
  border-radius: 0;
  border: 0;
}
/* Safari only understands the standard :fullscreen from 16.4; before that it
   is :-webkit-full-screen. Kept as its OWN block, never a selector list with
   the standard rule — a browser that doesn't recognise one selector in a list
   drops the whole rule, which would break the browsers that work today.
   Without it, older Safari takes the native path and leaves a 520px map on a
   black fullscreen surface. */
#map:-webkit-full-screen {
  height: 100%;
  width: 100%;
  border-radius: 0;
  border: 0;
}
/* Fallback for browsers without element requestFullscreen (iOS Safari) —
   app.js adds these classes instead. z-index clears the topbar/slideovers;
   the body class stops the page scrolling behind the expanded map. */
#map.is-expanded {
  position: fixed;
  inset: 0;
  height: auto;
  width: auto;
  z-index: 3000;
  border-radius: 0;
  border: 0;
}
body.map-expanded { overflow: hidden; }

/* Topcard is showing the last-known value because the most recent
   refresh failed (usually a wake-from-sleep / wifi-reconnect blip).
   bindError() applies this and a `title` tooltip explaining the
   staleness; the recoverAfterResume() retry loop clears both within
   ~25s if the network heals. */
.card.is-stale {
  filter: saturate(0.5) brightness(0.95);
  border-color: var(--warn);
}
.leaflet-container { background: var(--bg-deep); }
.leaflet-popup-content-wrapper, .leaflet-popup-tip {
  background: var(--panel);
  color: var(--text);
  border: 1px solid var(--line);
  box-shadow: 0 8px 24px rgba(0,0,0,0.30);
}
.leaflet-popup-content { margin: 10px 12px; font-size: 12.5px; line-height: 1.5; }
.leaflet-popup-content code { color: var(--muted); }
/* Marker tooltips (bus dots, VOR depot pins, geofence shapes). Stock Leaflet
   ships a white box with a white arrow, which over the dark theme's map reads
   as a hole punched in it — same tokens as the popup above instead.
   The arrow is a CSS triangle, i.e. a BORDER colour, so each direction needs
   its own rule; all four are here because Leaflet flips the direction on its
   own near a map edge even though every bind site asks for `top`. It takes
   --panel, not --line: an outlined arrow needs two stacked triangles, and a
   1px seam at the tip is more conspicuous than a borderless nub. */
.leaflet-tooltip {
  background: var(--panel);
  color: var(--text);
  border: 1px solid var(--line);
  box-shadow: 0 8px 24px rgba(0,0,0,0.30);
  font-size: 11.5px;
  line-height: 1.45;
  padding: 6px 8px;
}
.leaflet-tooltip-top::before { border-top-color: var(--panel); }
.leaflet-tooltip-bottom::before { border-bottom-color: var(--panel); }
.leaflet-tooltip-left::before { border-left-color: var(--panel); }
.leaflet-tooltip-right::before { border-right-color: var(--panel); }
.leaflet-control-attribution {
  background: color-mix(in srgb, var(--bg) 85%, transparent) !important;
  color: var(--muted) !important;
  max-width: 100%;
}
/* attachBasemap() moves the credit to the bottom-LEFT (the basemap button owns
   the bottom-right), so it grows rightwards from x=0 instead of being
   right-anchored, and the button is opaque and above it (z-index 1001 vs the
   1000 control pane). The Esri credit is ~390px wide, so on a narrow map — the
   phone dashboard, the vehicle panel's ~316px map — the tail would vanish under
   the button. Cap the CORNER BOX, not the credit itself: a percentage on the
   credit resolves against this shrink-to-fit box (measured: it wrapped at
   ~305px even on a 984px map), while the box's own percentage resolves against
   .leaflet-control-container, i.e. the map. So the credit stays on one line
   wherever there is room and wraps to two only when there isn't. */
.leaflet-bottom.leaflet-left { max-width: calc(100% - 56px); }
.leaflet-control-attribution a { color: var(--primary) !important; }

/* ──────────── Tables ──────────── */
.table-wrap {
  overflow: auto;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--panel);
  box-shadow: var(--shadow-card);
}
table { width: 100%; border-collapse: collapse; min-width: 720px; }
th, td {
  text-align: left;
  padding: 10px 14px;
  border-bottom: 1px solid var(--line);
  font-size: 13px;
  vertical-align: middle;
}
th {
  color: var(--muted);
  font-weight: 600;
  font-size: 10.5px;
  text-transform: uppercase;
  letter-spacing: 0.12em;
  background: var(--bg-deep);
  position: sticky;
  top: 0;
}
tr:last-child td { border-bottom: 0; }
tbody tr { transition: background 100ms ease; }
tbody tr:hover td { background: var(--primary-soft); cursor: pointer; }

/* ──────────── Bus detail slideover ──────────── */
.bus-detail {
  position: fixed;
  top: 56px;
  right: 0;
  width: 380px;
  max-width: 92vw;
  height: calc(100vh - 56px);
  background: var(--panel);
  border-left: 1px solid var(--line);
  box-shadow: -12px 0 32px rgba(0,0,0,0.30);
  /* Leaflet's zoom/attribution controls sit at z-index ~1000, so the
     drawer needs to win to avoid the Leaflet "+" / "−" buttons leaking
     over the panel near the top-left of the map. */
  z-index: 1100;
  overflow-y: auto;
  animation: slide-in 180ms ease-out;
}
.bus-detail[hidden] { display: none; }
@keyframes slide-in {
  from { transform: translateX(100%); }
  to   { transform: translateX(0); }
}
.bus-detail header {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  padding: 16px 18px 12px;
  border-bottom: 1px solid var(--line);
  position: sticky;
  top: 0;
  background: var(--panel);
  backdrop-filter: none;
  z-index: 1;
}
.bus-detail header h3 {
  margin: 0 0 2px;
  font-size: 15px;
  font-weight: 600;
  letter-spacing: -0.01em;
}
.bus-detail header #bd-sub { font-size: 12.5px; }
.bus-detail header button {
  background: transparent;
  border: 0;
  color: var(--muted);
  font-size: 22px;
  line-height: 1;
  cursor: pointer;
  padding: 0 6px;
}
.bus-detail header button:hover { color: var(--text); }
.bd-body { padding: 16px 18px 24px; display: flex; flex-direction: column; gap: 8px; }
.bd-row {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 10px;
  padding: 5px 0;
}
.bd-label {
  color: var(--muted);
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: 0.12em;
  font-weight: 600;
}
.bd-val { font-size: 14px; font-variant-numeric: tabular-nums; }
/* Faint helper sitting alongside the main bd-label — used to spell out
   triplet-value formats (e.g. "avg / max / min") so the number row
   reads cleanly without an extra header row. */
.bd-sublabel {
  font-size: 9.5px;
  font-weight: 500;
  letter-spacing: 0.08em;
  opacity: 0.7;
  margin-left: 4px;
}
.bd-val.mono {
  font-family: var(--font-mono);
  font-size: 12px;
  color: var(--muted);
}
.bd-bar {
  height: 6px;
  background: var(--panel-2);
  border-radius: var(--radius-sm);
  overflow: hidden;
  margin-bottom: 6px;
}
.bd-bar-fill {
  height: 100%;
  background: linear-gradient(90deg, var(--info), var(--good));
  transition: width 400ms ease-out;
}
.bd-section-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin: 16px 0 6px;
  font-size: 10.5px;
  text-transform: uppercase;
  color: var(--muted);
  font-weight: 600;
  letter-spacing: 0.12em;
}
.pulse {
  width: 8px; height: 8px; border-radius: 50%;
  background: var(--good);
  box-shadow: 0 0 0 0 rgba(10, 193, 53, 0.7);
  animation: pulse 1.6s infinite;
}
/* Last fetchCurrent() returned no usable data — API up but channels
   empty (e.g. demo VIN). Drop the animation so the dot reads as a
   status indicator, not a heartbeat. */
.pulse.is-stale {
  background: var(--warn);
  box-shadow: none;
  animation: none;
}
/* Every channel of the last fetchCurrent() threw — telemetry API is
   unreachable. */
.pulse.is-offline {
  background: var(--error);
  box-shadow: none;
  animation: none;
}
@keyframes pulse {
  0%   { box-shadow: 0 0 0 0 rgba(10, 193, 53, 0.7); }
  70%  { box-shadow: 0 0 0 8px rgba(10, 193, 53, 0); }
  100% { box-shadow: 0 0 0 0 rgba(10, 193, 53, 0); }
}
.bd-events {
  display: flex;
  flex-direction: column;
  gap: 6px;
  font-size: 12.5px;
}
.bd-events .bdev {
  background: var(--panel-2);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  padding: 8px 10px;
}
.bd-events .bdev .name { font-weight: 500; }
.bd-events .bdev .meta { color: var(--muted); font-size: 11.5px; margin-top: 2px; }
.bd-poll {
  font-size: 11px;
  color: var(--muted);
  margin-top: 14px;
  padding-top: 10px;
  border-top: 1px solid var(--line);
}

/* Group cell in the stationary report — group lists can be several
   names long; cap the width and let the tooltip show the full text. */
#report-table td.group-cell {
  max-width: 280px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  color: var(--muted);
  font-size: 12px;
}

/* 8 columns can push "Last moved" past the visible edge of .table-wrap
   on common laptop viewports. Lock the layout to percentage widths so
   every column stays in view; long values (group, geofence) ellipsize
   and the full text remains available via the title attribute. */
#report-table { table-layout: fixed; }
#report-table th:nth-child(1), #report-table td:nth-child(1) { width:  9%; } /* Fleet number */
#report-table th:nth-child(2), #report-table td:nth-child(2) { width: 13%; } /* Opportunity */
#report-table th:nth-child(3), #report-table td:nth-child(3) { width:  8%; } /* Reg */
#report-table th:nth-child(4), #report-table td:nth-child(4) { width: 14%; } /* VIN */
#report-table th:nth-child(5), #report-table td:nth-child(5) { width:  9%; } /* Vehicle category */
#report-table th:nth-child(6), #report-table td:nth-child(6) { width: 12%; } /* Bus type */
#report-table th:nth-child(7), #report-table td:nth-child(7) { width: 13%; } /* Operator */
#report-table th:nth-child(8), #report-table td:nth-child(8) { width: 12%; } /* Geofence */
#report-table th:nth-child(9), #report-table td:nth-child(9) { width: 10%; } /* Last moved */
#report-table td {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
#report-table td code {
  display: inline-block;
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  vertical-align: bottom;
}

/* ──────────── Sortable tables ──────────── */
table.sortable th[data-sort-key] {
  cursor: pointer;
  user-select: none;
  padding-right: 22px;
  position: relative;
}
/* Let the visible label truncate with an ellipsis instead of leaking
   under the sort indicator. table-layout:fixed columns (e.g. telematics)
   can be narrow; without min-width:0 the inline-block won't shrink. */
table.sortable th[data-sort-key] .th-label {
  display: inline-block;
  max-width: 100%;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  vertical-align: middle;
}
table.sortable th[data-sort-key]:hover { color: var(--text); }
table.sortable th[data-sort-key]:focus-visible {
  outline: 2px solid var(--primary);
  outline-offset: -2px;
}
table.sortable th .sort-ind {
  position: absolute;
  right: 8px;
  top: 50%;
  transform: translateY(-50%);
  font-size: 9px;
  line-height: 1;
  color: var(--muted);
  opacity: 0.45;
  transition: opacity 120ms ease, color 120ms ease;
}
table.sortable th[aria-sort="ascending"] .sort-ind::before  { content: "▲"; }
table.sortable th[aria-sort="descending"] .sort-ind::before { content: "▼"; }
table.sortable th[aria-sort="none"]       .sort-ind::before { content: "▾"; }
table.sortable th[aria-sort="ascending"] .sort-ind,
table.sortable th[aria-sort="descending"] .sort-ind {
  opacity: 1;
  color: var(--primary);
}

/* ──────────── Side menu (left drawer) ──────────── */
.menu-toggle {
  flex: 0 0 auto;
}
.side-menu-overlay {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.32);
  z-index: 1700;
  animation: fade-in 140ms ease-out;
}
.side-menu-overlay[hidden] { display: none; }
@keyframes fade-in {
  from { opacity: 0; }
  to   { opacity: 1; }
}

.side-menu {
  position: fixed;
  top: 0;
  left: 0;
  bottom: 0;
  width: 260px;
  max-width: 86vw;
  background: var(--panel);
  border-right: 1px solid var(--line);
  box-shadow: var(--shadow-drawer);
  z-index: 1800;
  display: flex;
  flex-direction: column;
  animation: slide-in-left 180ms ease-out;
}
.side-menu[hidden] { display: none; }
@keyframes slide-in-left {
  from { transform: translateX(-100%); }
  to   { transform: translateX(0); }
}
.side-menu-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 14px 16px;
  border-bottom: 1px solid var(--line);
}
.side-menu-title {
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--muted);
}
.side-menu-close {
  background: transparent;
  border: 0;
  color: var(--muted);
  font-size: 22px;
  line-height: 1;
  cursor: pointer;
  width: 32px;
  height: 32px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border-radius: var(--radius-sm);
  padding: 0;
  margin-right: -4px;
  transition: background 100ms ease, color 100ms ease;
}
.side-menu-close:hover { color: var(--text); background: var(--primary-soft); }
.side-menu-close:focus-visible {
  outline: 2px solid var(--primary);
  outline-offset: 2px;
}
.side-menu-list {
  list-style: none;
  margin: 0;
  /* No horizontal padding, and no gap — both are deliberate, and both moved
     INTO the items. They used to be here, which left every row flanked by two
     12px strips and separated by a 2px strip that belonged to this <ul> and so
     carried no `cursor: pointer`: dragging the mouse down the rail flipped
     between hand and arrow at every one of them. A sweep of the item column
     measured 86% pointer coverage, with all 472 rows containing dead pixels.
     The rows are now edge-to-edge and touching, and the visual inset is drawn
     by each item's ::before instead — see .side-menu-item. The vertical 12px
     stays: it is the gap to the head/foot, not a gap between rows. */
  padding: 12px 0;
  display: flex;
  flex-direction: column;
  gap: 0;
  flex: 1 1 auto;
  overflow-y: auto;
  /* The labels are `nowrap` so they hold one line while the rail narrows; this
     is what clips them instead of letting them spill over the icons. */
  overflow-x: hidden;
}
.side-menu-item {
  width: 100%;
  display: flex;
  align-items: center;
  gap: 10px;
  background: transparent;
  border: 0;
  color: var(--text);
  font: inherit;
  font-size: 13px;
  font-weight: 500;
  /* 24px = the 12px the <ul> used to pad by + the item's own 12px. The glyph
     therefore sits at exactly the same x as before (and its centre still lands
     on 34, which is what --rail-w-collapsed is derived from) while the button's
     box now reaches both rail edges, so the whole row is one hit target. */
  padding: 10px 24px;
  cursor: pointer;
  text-align: left;
  transition: color 100ms ease;
  /* Containing block for the highlight below. */
  position: relative;
}
/* The hover/active highlight, drawn INSET rather than as the button's own
   background. The button has to span the full rail width to give the cursor a
   continuous target, but the pill it paints should stay inset the way it always
   has — so the two are separated: the button is the hit area, this is the paint.
   `inset: 1px 12px` restores both insets at once — 12px each side, and 1px top
   and bottom which reads as the old 2px gap between two adjacent rows while the
   rows themselves stay touching.
   z-index: -1 puts it behind the icon and label without needing either of them
   positioned (the sub-items below are bare text nodes, which cannot be). It
   cannot vanish behind the rail: .side-menu is the stacking context here, so a
   negative-index child of a non-stacking-context button still paints above the
   rail's own background. */
.side-menu-item::before,
.side-menu-subitem::before {
  content: '';
  position: absolute;
  z-index: -1;
  inset: 1px 12px;
  border-radius: var(--radius-sm);
  transition: background 100ms ease;
}
.side-menu-item:hover::before { background: var(--primary-soft); }
.side-menu-item.is-active::before { background: var(--primary-soft); }
.side-menu-item.is-active { color: var(--primary); }
.side-menu-item svg { width: 20px; height: 20px; flex: 0 0 auto; }
/* The label must never wrap: a second line would change the item's height as
   the rail narrows, so the rail would grow taller while it got thinner. It is
   also what makes the collapse a pure clip — the icon holds its x throughout. */
.side-menu-item > span {
  white-space: nowrap;
  overflow: hidden;
}

/* Expandable submenu group (e.g. Reports) */
.side-menu-group { display: contents; }
.side-menu-group-toggle .caret {
  margin-left: auto;
  width: 14px;
  height: 14px;
  transition: transform 160ms ease;
  opacity: 0.7;
}
.side-menu-group-toggle[aria-expanded="false"] .caret {
  transform: rotate(-90deg);
}
.side-menu-sublist {
  list-style: none;
  margin: 0;
  /* Was `2px 0 6px` — two more strips belonging to a container rather than to a
     row, so two more places the cursor dropped back to an arrow (8 rows of it,
     right where you travel from Reports down into its children). The 1px top and
     bottom of each sub-item's ::before already separates the pills by the same
     2px the top-level rows use, and the 6px was only breathing room before the
     <ul>'s own 12px bottom padding. */
  padding: 0;
  display: flex;
  flex-direction: column;
  /* Same reason as the parent <ul>: a flex gap is a strip of the CONTAINER, so it
     carries no `cursor: pointer` and the arrow returns between every pair of
     sub-items. Measured as 6 full-width dead rows resolving to this element. */
  gap: 0;
  overflow: hidden;
}
.side-menu-group-toggle[aria-expanded="false"] + .side-menu-sublist {
  display: none;
}
.side-menu-subitem {
  width: 100%;
  /* Block, not the default inline-block. As an inline-block each of these sat in
     a line box inside its <li>, and the line box's baseline strut made the <li>
     a pixel taller than the button — one dead row under every sub-item, six more
     places the cursor flipped. The top-level items never had this because they
     are `display: flex`, which is already block-level. */
  display: block;
  background: transparent;
  border: 0;
  color: var(--text);
  font: inherit;
  font-size: 12.5px;
  text-align: left;
  /* 54px = the parent item's 24px padding + its 20px icon + the 10px gap, so a
     sub-item's text starts exactly under its group's label rather than near it.
     Was 42 against the <ul>'s old 12px padding; that padding is gone, so this
     absorbs it and the text has not moved. Right side is 24 for the same
     reason. */
  padding: 8px 24px 8px 54px;
  cursor: pointer;
  transition: color 100ms ease;
  /* Full-width hit area, inset highlight — see .side-menu-item::before. */
  position: relative;
}
.side-menu-subitem:hover::before { background: var(--primary-soft); }
.side-menu-subitem.is-active::before { background: var(--primary-soft); }
.side-menu-subitem.is-active {
  color: var(--primary);
  font-weight: 600;
}

/* The collapse control. Hidden by default because the default is the
   narrow-screen overlay drawer, where there is nothing to collapse to — the
   ≥900px block below turns it on. */
.side-menu-foot {
  display: none;
  justify-content: flex-end;
  padding: 8px 12px 12px;
  margin-top: 4px;
  border-top: 1px solid var(--line);
  flex: 0 0 auto;
}
.rail-toggle {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 36px;
  height: 36px;
  flex: 0 0 auto;
  padding: 0;
  background: transparent;
  border: 0;
  border-radius: var(--radius-sm);
  color: var(--muted);
  cursor: pointer;
  transition: background 100ms ease, color 100ms ease;
}
.rail-toggle:hover { background: var(--primary-soft); color: var(--text); }
.rail-toggle:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
.rail-toggle svg { width: 20px; height: 20px; }
/* Both glyphs ship; the rail state picks one. Expanded shows "collapse". */
.rail-icon-expand { display: none; }
:root[data-rail="collapsed"] .rail-icon-collapse { display: none; }
:root[data-rail="collapsed"] .rail-icon-expand { display: inline; }

/* ──────────── Persistent nav rail (≥900px) ────────────
   Above this width the side menu stops being an overlay drawer and becomes a
   permanent column that the content sits beside. Below it, the drawer is
   unchanged. 900px is a new media query — the other 900px values in this file
   are `max-width` box caps, not breakpoints — so if it needs to move, this is
   the only query to change.

   EVERY rule here is gated on `:root.rail-on`, which app.js sets from
   syncRailVisibility() — i.e. only when a rail is actually on screen. The
   breakpoint alone is not enough: the rail is hidden for a signed-out session
   (showLogin() tears it down, as it does the notification bell), so gating the
   <body> padding on width alone reserved 240px of blank gutter down the sign-in
   page and pushed the login card off-centre. Width says "a rail would fit";
   rail-on says "there is one".

   The rail stays `position: fixed` — deliberately, not incidentally. The
   dashboard shell (see the `body.view-dashboard` flex column further up) carries
   an explicit INVARIANT that no new IN-FLOW child of <body> may be added,
   because it would take a share of the flex height away from the map. A fixed
   rail is out of flow, so that shell is untouched and the offset is carried by
   padding instead. Do not "tidy" this into a body grid without re-reading that
   invariant: the 16 view <main>s are all direct body siblings, so there is no
   single content element to hand a grid area to. */
@media (min-width: 900px) {
  :root.rail-on body {
    padding-left: var(--rail-track);
    transition: padding-left var(--rail-ms) var(--ease);
  }
  /* The topbar is full-bleed ACROSS the rail, matching the reference shell: the
     brand lockup holds the same x at every rail width instead of sliding 172px
     sideways on every collapse. width:100% resolves against body's content box
     (viewport − rail), so the rail width is added back to reach the far edge,
     and the negative margin pulls the left edge back to x=0. Both animate on
     the same clock as the padding, so the bar and the rail move as one.
     Gated on rail-on with the padding it compensates for, and for a sharper
     reason: on its own the negative margin has nothing to cancel, so a
     signed-out page hung the whole topbar 240px off the left edge with the
     brand lockup out of view entirely. */
  :root.rail-on body > header {
    margin-left: calc(-1 * var(--rail-track));
    width: calc(100% + var(--rail-track));
    transition: margin-left var(--rail-ms) var(--ease), width var(--rail-ms) var(--ease);
    /* The internal seam. The bar is full-bleed, so its border-bottom ran the
       whole width — straight across the top of the rail, drawing a line through
       the middle of what is now one continuous chrome surface. The bar paints
       ABOVE the rail (z-index 1500 vs 1400), so the rail cannot cover it; the
       border has to go, and be redrawn only where it still means something. */
    border-bottom-color: transparent;
  }
  /* The chrome/content boundary, redrawn as the bottom edge of the bar ONLY to
     the right of the rail. Together with the rail's own border-right this traces
     one continuous L around the outside of the chrome: the vertical segment ends
     at x=rail width and this starts there, so they meet as a clean corner with
     no gap and no doubled pixel. `left` rides --rail-track on the same clock as
     everything else, so the corner stays joined mid-collapse.
     bottom:-1px, not 0. An absolutely positioned child resolves against the
     PADDING box, whose bottom edge sits above the 1px border — so bottom:0 would
     draw the line one pixel high and leave a hairline gap between it and the top
     of the rail. -1px puts it exactly over the transparent border it replaces,
     which is also why that border is only made transparent above and not removed:
     the 1px of layout has to stay, or --topbar-h changes and the rail moves. */
  :root.rail-on body > header::after {
    content: '';
    position: absolute;
    left: var(--rail-track);
    right: 0;
    bottom: -1px;
    height: 1px;
    background: var(--line);
    transition: left var(--rail-ms) var(--ease);
    pointer-events: none;
  }
  /* Nothing to open: the rail is always there. app.js also stops managing the
     `hidden` attribute above this width. */
  :root.rail-on .menu-toggle { display: none; }
  /* .side-menu-head is the drawer's title bar ("Menu" + ×). On the rail there
     is nothing to close, and the collapse control lives in the foot instead. */
  :root.rail-on .side-menu-head { display: none; }
  :root.rail-on .side-menu-foot { display: flex; }

  :root.rail-on .side-menu {
    top: var(--topbar-h);
    width: var(--rail-track);
    max-width: none;
    box-shadow: none;
    /* The slide-in keyframe belongs to the drawer; on the rail it would replay
       on every load and on every resize across the breakpoint. */
    animation: none;
    /* Under the sticky topbar (z-index 1500) so the header's translucent blur
       always wins, and under the drawer's own 1800 for the same reason. */
    z-index: 1400;
    transition: width var(--rail-ms) var(--ease);
  }

  /* Labels fade rather than slide. The delay on the way OUT (expanding) lets
     the rail reach most of its width before text appears, so the label never
     paints on top of a still-narrow rail; collapsing has no delay, so the text
     is gone before the width closes over it. `visibility` is what takes the
     labels out of the tab/screen-reader order once faded — opacity alone would
     leave an invisible but focusable, announced label. */
  :root.rail-on .side-menu-item > span,
  :root.rail-on .side-menu-group-toggle .caret {
    transition: opacity 160ms var(--ease) 100ms;
  }
  :root.rail-on[data-rail="collapsed"] .side-menu-item > span,
  :root.rail-on[data-rail="collapsed"] .side-menu-group-toggle .caret {
    opacity: 0;
    visibility: hidden;
    transition: opacity 160ms var(--ease), visibility 0s linear 160ms;
  }
  /* A collapsed rail has no room for sub-items. Clicking the Reports group while
     collapsed expands the rail first (app.js) rather than opening a flyout, so
     the group is never expanded-but-invisible. */
  :root.rail-on[data-rail="collapsed"] .side-menu-sublist { display: none; }
  /* Centre the toggle on the icon column instead of leaving it hard against the
     right edge of a 68px rail. */
  :root.rail-on[data-rail="collapsed"] .side-menu-foot { justify-content: center; }
}

/* The rail's width, the body padding and the topbar's full-bleed compensation
   are one motion, so they are all switched off together. Collapsing still
   works — it just lands instantly.
   The selectors mirror the gated ones above CHARACTER FOR CHARACTER, including
   the `:root.rail-on[data-rail="collapsed"]` pair. A media query contributes no
   specificity, so a bare `body { transition: none }` here would simply lose to
   `:root.rail-on body` and the motion would play anyway; matching the selector
   and relying on source order is what makes this win. */
@media (prefers-reduced-motion: reduce) {
  :root.rail-on body,
  :root.rail-on body > header,
  :root.rail-on body > header::after,
  :root.rail-on .side-menu,
  :root.rail-on .side-menu-item > span,
  :root.rail-on .side-menu-group-toggle .caret,
  :root.rail-on[data-rail="collapsed"] .side-menu-item > span,
  :root.rail-on[data-rail="collapsed"] .side-menu-group-toggle .caret {
    transition: none;
  }
  .side-menu { animation: none; }
}

/* ──────────── Reports view ──────────── */
.reports-view {
  padding: 24px;
  max-width: 1280px;
  margin: 0 auto;
}

/* The 1280px cap above (and the matching ones on `main` and #geofences-view)
   leaves roughly half of a large/ultrawide display empty, while the
   14-column fixed-layout Warranty table has to cram itself into that
   width and ends up ellipsising its own column HEADERS — the user can no
   longer tell which column is which. Step the ceiling up at larger
   viewports rather than jumping straight to full width, so the layout
   grows gradually instead of suddenly sprawling edge-to-edge. All three
   selectors are repeated here (matching their original specificity) so
   none of them is left behind by a less-specific rule.
   #login-view is deliberately excluded: a sign-in box centred in a
   1720px-wide container reads as broken, so it keeps the narrow 1280px
   measure at every viewport width. */
@media (min-width: 1500px) {
  main, .reports-view, #geofences-view {
    max-width: 1460px;
  }
}
@media (min-width: 1800px) {
  main, .reports-view, #geofences-view {
    max-width: 1720px;
  }
}

.report-head { margin-bottom: 18px; }
.report-title {
  font-size: 18px;
  font-weight: 600;
  letter-spacing: -0.01em;
  text-transform: none;
  color: var(--text);
  margin: 0 0 4px;
}
.report-sub {
  margin: 0;
  color: var(--muted);
  font-size: 13px;
}
.report-controls {
  display: flex;
  flex-wrap: wrap;
  gap: 14px;
  align-items: flex-end;
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 14px 16px;
  margin-bottom: 18px;
  box-shadow: var(--shadow-card);
}
.report-control {
  display: flex;
  flex-direction: column;
  gap: 6px;
  min-width: 180px;
}
/* `display: flex` above out-ranks the user-agent's `[hidden] { display: none }`,
   so setting the attribute changed the DOM and nothing on screen. That is how the
   operator picker stayed visible on the Telematics and Stationary reports for an
   operator-scoped user: applyScopeControlGating() set `hidden = true` on the
   wrapper and the browser kept laying it out.
   Third time this trap has bitten in this feature (.users-duplicates and
   .view-as-banner were the others), which is why there is now a test asserting
   every selector the scope gating hides carries this guard. */
.report-control[hidden] { display: none; }
.report-control label {
  color: var(--muted);
  font-size: 10.5px;
  font-weight: 600;
  letter-spacing: 0.12em;
  text-transform: uppercase;
}
/* Same --control-h as the buttons and the multi-select trigger beside them.
   .report-controls is align-items: flex-end, so unequal heights used to hide
   as a 1-4px stagger along the TOP of each row rather than an obvious break —
   but a 34px select next to a 38px picker is still two different controls. */
.report-control select,
.report-control input[type="number"],
.report-control input[type="date"],
.report-control input[type="search"],
.report-control input[type="text"] {
  background: var(--panel);
  color: var(--text);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  padding: 4px 10px;
  min-height: var(--control-h);
  font: inherit;
  font-size: 13px;
  transition: border-color 120ms ease, box-shadow 120ms ease;
}
.report-control select:focus,
.report-control input[type="number"]:focus,
.report-control input[type="date"]:focus,
.report-control input[type="search"]:focus,
.report-control input[type="text"]:focus {
  outline: none;
  border-color: var(--primary);
  box-shadow: 0 0 0 3px var(--primary-soft);
}
.report-control .input-suffix {
  display: flex;
  align-items: stretch;
  gap: 0;
}
.report-control .input-suffix input[type="number"] {
  flex: 1 1 auto;
  border-top-right-radius: 0;
  border-bottom-right-radius: 0;
  min-width: 0;
  /* Hide native spinner only when there's no value, to keep the
     placeholder reading cleanly. */
}
.report-control .input-suffix-unit {
  display: inline-flex;
  align-items: center;
  padding: 0 10px;
  background: var(--panel-2);
  border: 1px solid var(--line);
  border-left: 0;
  border-top-right-radius: var(--radius-sm);
  border-bottom-right-radius: var(--radius-sm);
  color: var(--muted);
  font-size: 12.5px;
  letter-spacing: 0.02em;
}
/* Actions row (count, status, Export). Forced onto its own line with
   a top separator so it reads as the "footer" of the filter card —
   without it, the row can wrap to a different line than the filters
   depending on viewport width and look disconnected. */
.report-control-actions {
  flex: 1 1 100%;
  flex-direction: row;
  /* Wrap rather than clip. This row grows over time (count, status, and on the
     VOR report an Import button beside Export CSV); without wrapping, a narrow
     viewport pushed the rightmost button off the panel edge — which it already
     did with just Export CSV on a phone-width screen. */
  flex-wrap: wrap;
  align-items: center;
  justify-content: flex-end;
  gap: 12px;
  min-width: 0;
  padding-top: 12px;
  margin-top: 4px;
  border-top: 1px solid var(--line);
}
/* …unless there is nothing above it to divide. The VOR report has no filter
   controls — this actions row is the only child of its .report-controls — so the
   rule drew a line across the top of an otherwise empty panel. Keyed on
   :first-child rather than a VOR-specific class so it stays correct in both
   directions: give that report filters again and the divider comes back on its
   own. */
.report-control-actions:first-child {
  padding-top: 0;
  margin-top: 0;
  border-top: 0;
}
/* padding-block is 4px, not the 8px it used to be, because the height now
   comes from --control-h (38px) — the same 38px the old padding produced, but
   declared once instead of re-derived per control. inline-flex + centring is
   what makes the min-height actually centre the label. */
.btn-primary {
  background: var(--primary);
  color: var(--primary-ink);
  border: 1px solid var(--primary);
  border-radius: var(--radius-sm);
  padding: 4px 14px;
  min-height: var(--control-h);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font: inherit;
  font-size: 13px;
  font-weight: 600;
  cursor: pointer;
  transition: filter 120ms ease, transform 120ms ease;
}
.btn-primary:hover { filter: brightness(1.06); }
.btn-primary:active { transform: translateY(1px); }
.btn-primary:disabled {
  opacity: 0.5;
  cursor: not-allowed;
  filter: none;
  transform: none;
}
.btn-secondary {
  background: transparent;
  color: var(--text);
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-sm);
  padding: 4px 14px;
  min-height: var(--control-h);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font: inherit;
  font-size: 13px;
  font-weight: 500;
  cursor: pointer;
  transition: background 120ms ease, border-color 120ms ease;
}
.btn-secondary:hover { background: var(--primary-soft); border-color: var(--primary); }
.btn-secondary:active { transform: translateY(1px); }
.btn-secondary:disabled { opacity: 0.5; cursor: not-allowed; }

/* Both buttons above now carry an author-origin `display` (inline-flex, to
   centre their label inside --control-h), which outranks the UA sheet's
   `[hidden] { display: none }` — same trap as .bd-route-line[hidden] above.
   Without this, #ge-subscribe (index.html, .btn-secondary + hidden, toggled
   by `subBtn.hidden = !sel.value`) renders on the Geofence events report with
   "All geofences" selected, and clicking it hits the `if (!gid) return;`
   early-out: a visible, dead button. */
.btn-primary[hidden],
.btn-secondary[hidden] { display: none; }

/* An <a> wearing .btn-secondary inherits the page line-height, which a
   <button> would have ignored — so without `line-height: normal` the link is
   the one control on the row whose CONTENT is taller than the others' and it
   used to render at 34px against their 38px. Centring and the shared
   --control-h now come from .btn-secondary itself; this modifier is left with
   only what is genuinely anchor-specific. */
.btn-secondary.btn-secondary-link {
  line-height: normal;
  text-decoration: none;
}
.btn-secondary.btn-secondary-link:focus { outline: none; }
.btn-secondary.btn-secondary-link:focus-visible {
  outline: 2px solid var(--primary);
  outline-offset: 2px;
}

/* Compact button modifier — for action buttons sitting inside table rows
   where the full-size control makes the row taller than it needs to be.
   min-height is reset deliberately: --control-h is the height of a control on
   a filter/action ROW, and inheriting it here would erase the entire point of
   this modifier. */
.btn-primary.btn-sm,
.btn-secondary.btn-sm {
  padding: 4px 10px;
  min-height: 0;
  font-size: 12px;
}

/* Destructive secondary — same shape as btn-secondary but recoloured to
   signal a removal. Hover stays subtle so the row doesn't fight the
   adjacent Edit button for attention. */
.btn-secondary.btn-danger {
  color: var(--error);
  border-color: color-mix(in srgb, var(--error) 38%, var(--line-strong));
}
.btn-secondary.btn-danger:hover {
  background: color-mix(in srgb, var(--error) 12%, transparent);
  border-color: var(--error);
}

/* Multi-select group picker — reused by both reports */
.group-multi {
  position: relative;
  min-width: 220px;
}
.group-multi-trigger {
  width: 100%;
  background: var(--panel);
  color: var(--text);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  padding: 4px 10px;
  min-height: var(--control-h);
  font: inherit;
  font-size: 13px;
  cursor: pointer;
  display: flex;
  align-items: center;
  gap: 8px;
  transition: border-color 120ms ease, box-shadow 120ms ease;
}
.group-multi-trigger:hover { border-color: var(--line-strong); }
.group-multi-trigger[aria-expanded="true"] {
  border-color: var(--primary);
  box-shadow: 0 0 0 3px var(--primary-soft);
}
.group-multi-label {
  flex: 1 1 auto;
  text-align: left;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.group-multi-caret {
  width: 14px;
  height: 14px;
  opacity: 0.7;
  flex: 0 0 auto;
  transition: transform 160ms ease;
}
.group-multi-trigger[aria-expanded="true"] .group-multi-caret { transform: rotate(180deg); }
.group-multi-panel {
  position: absolute;
  top: calc(100% + 6px);
  left: 0;
  min-width: 100%;
  max-width: 360px;
  width: max-content;
  background: var(--panel);
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-sm);
  box-shadow: 0 12px 32px rgba(0,0,0,0.30);
  z-index: 1500;
  padding: 8px;
  display: flex;
  flex-direction: column;
  gap: 6px;
}
.group-multi-panel[hidden] { display: none; }
.group-multi-search {
  width: 100%;
  background: var(--panel-2);
  color: var(--text);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  padding: 6px 10px;
  font: inherit;
  font-size: 12.5px;
}
/* Opts OUT of --control-h, the same call as .btn-sm and .wmap-close: this is
   panel-internal furniture, not a control on a row, and 38px filter boxes
   would bloat every popover that has one. It needs saying explicitly because
   the pickers inside .report-controls sit within a .report-control, so
   `.report-control input[type="search"]` (0,2,1) reaches them and had already
   pushed the warranty and reports panels to 38px while the dashboard one —
   whose picker is in .filter-bar — stayed compact, i.e. the same popover
   rendered at two heights depending on which page it was on.
   The leading .group-multi is not decoration: without it this selector is
   (0,2,1), exactly TYING .report-control input[type="search"], and would win
   only by appearing later in the file — move either block and the 38px
   popover search silently comes back. .group-multi-panel is always a child of
   .group-multi (all ten in index.html, and the two app.js query sites resolve
   the panel from inside one), so the extra class costs nothing and buys a real
   (0,3,1). */
.group-multi .group-multi-panel input.group-multi-search {
  min-height: 0;
  padding: 6px 10px;
  /* Same selector also has to restate the 12.5px, for the same reason: the
     .report-control rule's font-size reaches in here too and left the panel
     0.75px taller on those pages than on the dashboard. One popover, one
     size, wherever its trigger happens to live. */
  font-size: 12.5px;
}
.group-multi-search:focus {
  outline: none;
  border-color: var(--primary);
  box-shadow: 0 0 0 2px var(--primary-soft);
}
.group-multi-actions {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  padding: 0 2px;
  font-size: 11.5px;
}
.group-multi-clear {
  background: transparent;
  border: 0;
  color: var(--primary);
  font: inherit;
  font-size: 11.5px;
  font-weight: 600;
  cursor: pointer;
  padding: 2px 4px;
}
.group-multi-clear:hover { text-decoration: underline; }
.group-multi-clear:disabled {
  color: var(--muted);
  cursor: default;
  text-decoration: none;
}
.group-multi-summary { font-size: 11px; }
.group-multi-list {
  list-style: none;
  margin: 0;
  padding: 0;
  max-height: 280px;
  overflow-y: auto;
  border-top: 1px solid var(--line);
  padding-top: 4px;
}
.group-multi-list li { margin: 0; }
.group-multi-item {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px 8px;
  border-radius: calc(var(--radius-sm) - 2px);
  font-size: 12.5px;
  cursor: pointer;
  color: var(--text);
}
.group-multi-item:hover { background: var(--primary-soft); }
.group-multi-item input[type="checkbox"] {
  width: 14px;
  height: 14px;
  accent-color: var(--primary);
  flex: 0 0 auto;
}
.group-multi-item span {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* Optional per-row count on the CHECKBOX list (createOperatorMultiSelect).
   Mirrors .ss-count on the single-select below; margin-left:auto rather than
   justify-content on the row because the row also holds the checkbox, which
   must stay hard against the left edge. */
.group-multi-item .gm-count {
  margin-left: auto;
  color: var(--muted);
  font-size: 11.5px;
  flex: 0 0 auto;
}
.group-multi-empty {
  padding: 10px;
  color: var(--muted);
  font-size: 12px;
  text-align: center;
}

/* Single-select searchable combobox items (createSearchableSelect), reusing the
   .group-multi shell. Rendered as buttons rather than checkbox labels. */
.ss-item {
  width: 100%;
  background: transparent;
  border: 0;
  justify-content: space-between;
  font: inherit;
  text-align: left;
}
.ss-item .ss-name {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.ss-item .ss-count {
  color: var(--muted);
  font-size: 11.5px;
  flex: 0 0 auto;
  margin-left: 8px;
}
.ss-item.is-selected {
  background: var(--primary-soft);
  color: var(--primary);
  font-weight: 600;
}
.ss-item.is-selected .ss-count { color: var(--primary); }

/* Telematics-report specific controls */
/* Both usages are a control inside .report-controls, so it takes --control-h
   like the selects and pickers on the same row. Without it the row's
   `align-items: flex-end` bottom-aligns a ~20px block against 38px neighbours
   and its uppercase label ends up visibly lower than theirs. */
.checkbox-row {
  display: flex;
  gap: 10px;
  flex-wrap: wrap;
  align-items: center;
  min-height: var(--control-h);
  padding: 0;
}
.checkbox {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font-size: 12.5px;
  color: var(--text);
  cursor: pointer;
  user-select: none;
  text-transform: none;
  letter-spacing: 0;
  font-weight: 500;
}
.checkbox input[type="checkbox"] {
  width: 14px;
  height: 14px;
  accent-color: var(--primary);
  cursor: pointer;
}
.report-control select.tri-state { min-width: 110px; }

/* Yes / No availability cell */
#telematics-table td.avail-yes { color: var(--good); font-weight: 600; }
#telematics-table td.avail-no  { color: var(--error); font-weight: 600; }
#telematics-table td.group-cell {
  color: var(--muted);
  font-size: 12px;
}
/* 10 columns at content-sized widths overflow common laptop viewports
   once real data lands. Lock to a fixed layout with percentage column
   widths so the table stays inside .table-wrap; every cell truncates
   with an ellipsis and the full value sits in the title attribute. */
#telematics-table { table-layout: fixed; }
#telematics-table th:nth-child(1),  #telematics-table td:nth-child(1)  { width: 10%; } /* Name */
#telematics-table th:nth-child(2),  #telematics-table td:nth-child(2)  { width: 12%; } /* Opportunity */
#telematics-table th:nth-child(3),  #telematics-table td:nth-child(3)  { width:  7%; } /* Reg */
#telematics-table th:nth-child(4),  #telematics-table td:nth-child(4)  { width: 12%; } /* VIN */
#telematics-table th:nth-child(5),  #telematics-table td:nth-child(5)  { width:  7%; } /* Power */
#telematics-table th:nth-child(6),  #telematics-table td:nth-child(6)  { width: 10%; } /* Type */
#telematics-table th:nth-child(7),  #telematics-table td:nth-child(7)  { width: 12%; } /* Group */
#telematics-table th:nth-child(8),  #telematics-table td:nth-child(8)  { width: 12%; } /* Geofence */
#telematics-table th:nth-child(9),  #telematics-table td:nth-child(9)  { width:  6%; } /* GPS */
#telematics-table th:nth-child(10), #telematics-table td:nth-child(10) { width:  6%; } /* Cabin Temp */
#telematics-table th:nth-child(11), #telematics-table td:nth-child(11) { width:  6%; } /* Faults */
#telematics-table td {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* The VIN cell wraps the value in <code>; let it ellipsize too. */
#telematics-table td code {
  display: inline-block;
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  vertical-align: bottom;
}
#telematics-status:empty { display: none; }

/* ──────────── Geofences view ──────────── */
#geofences-view {
  padding: 24px;
  max-width: 1280px;
  margin: 0 auto;
}
.geofences-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  margin-bottom: 18px;
  flex-wrap: wrap;
}
/* The heading is the only thing left on the left-hand side (its sub-line is
   gone), so its 4px bottom margin would show as a half-line of drift against
   the controls it sits beside. Zeroed here rather than on .report-title, which
   every report page still uses under a sub-line. */
.geofences-head .report-title { margin-bottom: 0; }
/* Every child is a single --control-h control, so centring is both safe and
   what keeps a wrapped second line reading as a row rather than a stack. */
.geofences-actions { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
/* Kept as a wrapper even though it now holds only the picker: it is what
   carries the narrower min-width and the right-anchored panel override below,
   neither of which belongs on the shared .group-multi. */
.geofence-operator-filter {
  display: flex;
  flex-direction: column;
  /* Narrower than the 220px .group-multi default so the whole action row
     still fits one line at 1280px alongside the four buttons. */
  min-width: 200px;
}
/* Same trap as .report-control above: an author `display` beats [hidden]. */
.geofence-operator-filter[hidden] { display: none; }
.geofence-operator-filter .group-multi { min-width: 0; width: 100%; }
/* Right-anchored panel, unlike the report/warranty pickers. Those sit at the
   LEFT of their control row, so the default left:0 / width:max-content panel
   grows harmlessly inwards. This one is the LAST item of a right-aligned
   action row, where the same panel runs ~116px past the viewport at 1280px
   and drags a horizontal scrollbar onto the page. Opening leftwards keeps it
   inside the page at every width the row stays unwrapped. */
.geofence-operator-filter .group-multi-panel { left: auto; right: 0; }
.geofences-layout {
  display: grid;
  grid-template-columns: minmax(240px, 320px) 1fr;
  gap: 18px;
  align-items: start;
}
@media (max-width: 760px) {
  .geofences-layout { grid-template-columns: 1fr; }
}

.geofence-list-wrap {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 12px;
  box-shadow: var(--shadow-card);
  max-height: 620px;
  display: flex;
  flex-direction: column;
  gap: 8px;
  overflow: hidden;
}
.geofence-list-head {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 2px 4px 8px;
  border-bottom: 1px solid var(--line);
  font-size: 10.5px;
  font-weight: 600;
  letter-spacing: 0.12em;
  text-transform: uppercase;
}
/* The count trails the label and everything after it is pushed to the far end,
   which is what `justify-content: space-between` used to do for two children
   and gets wrong for three (it would strand the count in the middle). */
.geofence-count { font-size: 11px; letter-spacing: 0; text-transform: none; margin-right: auto; }
/* Occupancy toggle, in the head of the panel it filters. Same recipe as
   .rs-chip — the app's on/off filter shape — one size down, and with the
   head's uppercase/letter-spacing undone: the head is a label row, the chip
   is a control sitting in it. */
.gf-filter-chip {
  background: var(--panel-2);
  border: 1px solid var(--line);
  color: var(--muted);
  font: inherit;
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0;
  text-transform: none;
  white-space: nowrap;
  padding: 3px 8px;
  border-radius: 999px;
  cursor: pointer;
  transition: background 100ms ease, border-color 100ms ease, color 100ms ease;
}
.gf-filter-chip:hover { background: var(--primary-soft); border-color: var(--primary); color: var(--text); }
.gf-filter-chip[aria-pressed="true"] {
  background: var(--primary);
  color: var(--primary-ink);
  border-color: var(--primary);
}
.gf-filter-chip:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
/* Operator-scope users get a read-only page (see applyScopeControlGating), and
   this chip is the one control in the head that survives — nothing hides it
   today, but it carries no author `display`, so the UA [hidden] rule works on
   it unaided if that ever changes. */
/* Occupancy badge on a list row. Sits in the name column's grid cell beside
   the name, which is why the name element itself must stay ellipsised. */
.geofence-list .gf-inside {
  font-size: 11px;
  color: var(--muted);
  white-space: nowrap;
  flex: 0 0 auto;
}
.geofence-list .gf-name-wrap {
  display: flex;
  align-items: baseline;
  gap: 6px;
  min-width: 0;
}
.geofence-list {
  list-style: none;
  margin: 0;
  padding: 0;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: 4px;
}
.geofence-list li {
  display: grid;
  grid-template-columns: 1fr auto;
  align-items: center;
  gap: 6px;
  padding: 8px 10px;
  border-radius: var(--radius-sm);
  background: transparent;
  cursor: pointer;
  transition: background 100ms ease;
}
.geofence-list li:hover { background: var(--primary-soft); }
.geofence-list li.is-active {
  background: var(--primary-soft);
  /* Inset shadow instead of `outline` so the corner radius is honoured
     and the wrapper's overflow: hidden can't clip the top edges. */
  box-shadow: inset 0 0 0 1px var(--primary);
}
.geofence-list .gf-name {
  font-size: 13px;
  font-weight: 500;
  color: var(--text);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.geofence-list .gf-actions {
  display: flex;
  gap: 2px;
}
.gf-icon-btn {
  background: transparent;
  border: 0;
  color: var(--muted);
  padding: 4px;
  border-radius: var(--radius-sm);
  cursor: pointer;
  transition: background 100ms ease, color 100ms ease, opacity 100ms ease;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  line-height: 0;
}
.gf-icon-btn svg { display: block; }
.gf-icon-btn:hover { background: var(--line); color: var(--text); }
.gf-icon-btn.danger:hover { color: var(--error); }
/* Reveal-on-hover: the actions are visually de-emphasised when the row
   is idle so the name gets the row's full width. Hovering, focusing,
   or selecting the row brings them to full opacity. On touch devices
   (no hover capability) we leave them visible inline. */
@media (hover: hover) {
  .geofence-list .gf-actions { opacity: 0.18; }
  .geofence-list li:hover .gf-actions,
  .geofence-list li.is-active .gf-actions,
  .geofence-list li:focus-within .gf-actions { opacity: 1; }
}
.geofence-empty {
  padding: 14px 8px;
  font-size: 12.5px;
  text-align: center;
}

.geofence-map-wrap {
  position: relative;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--panel);
  overflow: hidden;
  box-shadow: var(--shadow-card);
  min-height: 620px;
  isolation: isolate;
}
#geofence-map {
  width: 100%;
  height: 620px;
}
.geofence-status {
  position: absolute;
  /* Anchored bottom-centre so transient drawing instructions and the
     edit-actions pill share the same spot, clear of the top-right search and
     top-left zoom controls. Lifted ABOVE the bottom band rather than in it:
     that band now holds the attribution credit (bottom-left, and it wraps to
     two lines ≈39px tall on a narrow editor) and the basemap button
     (bottom-right), and a centred pill measurably overlapped both. The
     max-width cap is belt-and-braces so a long instruction can't stretch to
     the edges either. */
  bottom: 52px;
  left: 50%;
  transform: translateX(-50%);
  max-width: calc(100% - 112px);
  background: color-mix(in srgb, var(--bg) 88%, transparent);
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-pill);
  padding: 6px 14px;
  font-size: 12px;
  color: var(--text);
  z-index: 1200;
  pointer-events: none;
  box-shadow: 0 4px 16px rgba(0,0,0,0.25);
}
.geofence-status[hidden] { display: none; }

/* ──────────── App modal (reusable prompt / confirm) ──────────── */
.app-modal {
  position: fixed;
  inset: 0;
  z-index: 2000;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
}
.app-modal[hidden] { display: none; }
.app-modal-backdrop {
  position: absolute;
  inset: 0;
  background: rgba(0, 0, 0, 0.42);
  animation: fade-in 140ms ease-out;
}
.app-modal-card {
  position: relative;
  background: var(--panel);
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-lg);
  box-shadow: 0 24px 48px rgba(0, 0, 0, 0.40);
  padding: 22px;
  width: 100%;
  max-width: 440px;
  display: flex;
  flex-direction: column;
  gap: 12px;
  animation: modal-in 160ms ease-out;
}
@keyframes modal-in {
  from { opacity: 0; transform: translateY(6px) scale(0.98); }
  to   { opacity: 1; transform: translateY(0)   scale(1); }
}
.app-modal-title {
  margin: 0;
  font-size: 16px;
  font-weight: 600;
  letter-spacing: -0.01em;
  color: var(--text);
}
.app-modal-message {
  margin: 0;
  font-size: 12.5px;
  /* Multi-paragraph confirmations (the VOR snapshot import states what it is
     about to replace) are set as text with blank lines, so newlines have to
     survive. No effect on the single-line messages. */
  white-space: pre-line;
}
.app-modal-message:empty { display: none; }
.app-modal-input {
  width: 100%;
  background: var(--panel-2);
  color: var(--text);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  padding: 9px 11px;
  font: inherit;
  font-size: 14px;
  transition: border-color 120ms ease, box-shadow 120ms ease;
}
.app-modal-input:focus {
  outline: none;
  border-color: var(--primary);
  box-shadow: 0 0 0 3px var(--primary-soft);
}
/* Pasted rows. Monospace because the content is delimited columns and the eye
   needs to see whether they line up; nowrap + scroll because wrapping a 97-column
   row makes it unreadable and hides how much was actually pasted. */
.app-modal-textarea {
  width: 100%;
  background: var(--panel-2);
  color: var(--text);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  padding: 9px 11px;
  font-family: var(--font-mono);
  font-size: 12px;
  line-height: 1.45;
  resize: vertical;
  white-space: pre;
  overflow: auto;
  transition: border-color 120ms ease, box-shadow 120ms ease;
}
.app-modal-textarea:focus {
  outline: none;
  border-color: var(--primary);
  box-shadow: 0 0 0 3px var(--primary-soft);
}
.app-modal-textarea[hidden] { display: none; }

.app-modal-error {
  color: var(--error);
  font-size: 12px;
  min-height: 1em;
}
.app-modal-error:empty { min-height: 0; }
.app-modal-actions {
  display: flex;
  justify-content: flex-end;
  gap: 8px;
  margin-top: 4px;
}
.app-modal-btn {
  background: transparent;
  color: var(--text);
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-sm);
  padding: 8px 14px;
  font: inherit;
  font-size: 13px;
  font-weight: 500;
  cursor: pointer;
  transition: background 120ms ease, border-color 120ms ease, filter 120ms ease;
}
.app-modal-btn:hover { background: var(--primary-soft); }
.app-modal-btn.primary {
  background: var(--primary);
  color: var(--primary-ink);
  border-color: var(--primary);
  font-weight: 600;
}
.app-modal-btn.primary:hover { filter: brightness(1.06); background: var(--primary); }
.app-modal-btn.danger {
  background: var(--error);
  color: var(--text);
  border-color: var(--error);
  font-weight: 600;
}
.app-modal-btn.danger:hover { filter: brightness(1.08); }
.app-modal-input[hidden] { display: none; }

/* Column-picker variant — used by report CSV export dialogs. */
.app-modal-columns {
  display: flex;
  flex-direction: column;
  gap: 4px;
  max-height: 320px;
  overflow-y: auto;
  padding: 6px;
  background: var(--panel-2);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
}
.app-modal-columns[hidden] { display: none; }
.app-modal-columns .col-row {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px 8px;
  font-size: 13px;
  color: var(--text);
  cursor: pointer;
  border-radius: calc(var(--radius-sm) - 2px);
  user-select: none;
}
.app-modal-columns .col-row:hover { background: var(--primary-soft); }
.app-modal-columns .col-row input[type="checkbox"] {
  width: 14px;
  height: 14px;
  accent-color: var(--primary);
  flex: 0 0 auto;
}
.app-modal-columns .col-toolbar {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 0 4px 4px;
  font-size: 11.5px;
  color: var(--muted);
}
.app-modal-columns .col-toolbar button {
  background: transparent;
  border: 0;
  color: var(--primary);
  font: inherit;
  font-size: 11.5px;
  font-weight: 600;
  cursor: pointer;
  padding: 2px 6px;
}
.app-modal-columns .col-toolbar button:hover { text-decoration: underline; }
.app-modal-btn:active { transform: translateY(1px); }
.app-modal-btn:disabled {
  opacity: 0.5;
  cursor: not-allowed;
  transform: none;
}

.geofence-fleet-section {
  margin-top: 22px;
}
.geofence-fleet-empty {
  background: var(--panel);
  border: 1px dashed var(--line-strong);
  border-radius: var(--radius);
  padding: 18px;
  font-size: 13px;
  text-align: center;
}
#geofence-fleet-empty[hidden] { display: none; }
#geofence-fleet-count:empty { display: none; }

/* Geofence map location search — floating top-left pill over the map. */
.geofence-search {
  position: absolute;
  top: 12px;
  /* Anchored top-right so it doesn't overlap Leaflet's zoom controls
     (which sit top-left). On narrow viewports it shrinks before the
     map does. */
  right: 12px;
  left: auto;
  z-index: 1100;
  display: flex;
  align-items: center;
  gap: 4px;
  width: 360px;
  max-width: calc(100% - 24px);
  background: color-mix(in srgb, var(--panel) 92%, transparent);
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-sm);
  padding: 4px 4px 4px 6px;
  box-shadow: 0 6px 20px rgba(0,0,0,0.30);
  backdrop-filter: saturate(140%) blur(8px);
  -webkit-backdrop-filter: saturate(140%) blur(8px);
}
/* No --control-h here either. This field is internal to the floating search
   shell over the map — borderless, sharing one bordered container with the
   two icon buttons — so the compound is the control and the input is a part
   of it. The shell's own height comes from its padding plus this input. */
.geofence-search input {
  flex: 1 1 auto;
  min-width: 0;
  background: transparent;
  border: 0;
  color: var(--text);
  font: inherit;
  font-size: 13px;
  padding: 6px 8px;
}
.geofence-search input::placeholder { color: var(--muted); }
.geofence-search input:focus { outline: none; }
.geofence-search button {
  flex: 0 0 auto;
  background: var(--primary);
  color: var(--primary-ink);
  border: 0;
  border-radius: calc(var(--radius-sm) - 2px);
  padding: 6px 8px;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  transition: filter 120ms ease, transform 120ms ease;
}
.geofence-search button:hover { filter: brightness(1.08); }
.geofence-search button:active { transform: translateY(1px); }
.geofence-search button:disabled { opacity: 0.55; cursor: progress; }
.geofence-search button svg { width: 16px; height: 16px; }
.geofence-search .geofence-search-clear {
  background: transparent;
  color: var(--muted);
  border: 1px solid var(--line);
}
.geofence-search .geofence-search-clear:hover {
  background: var(--primary-soft);
  color: var(--text);
  filter: none;
}
.geofence-search .geofence-search-clear[hidden] { display: none; }

/* Marker dropped by the search — visible orange pin, no Leaflet image deps. */
.geofence-search-pin {
  display: flex;
  align-items: flex-end;
  justify-content: center;
  pointer-events: none;
  filter: drop-shadow(0 4px 6px rgba(0,0,0,0.35));
}
.geofence-search-pin svg { width: 28px; height: 36px; }

/* Tone down leaflet-draw's default chrome to match the rest of the UI. */
.leaflet-draw-toolbar a {
  background-color: var(--panel) !important;
  border-color: var(--line-strong) !important;
}
.leaflet-draw-actions a {
  background: var(--panel) !important;
  color: var(--text) !important;
}

/* ──────────── Footer ──────────── */
footer { margin: 40px 0 20px; }
footer button {
  background: transparent;
  color: var(--muted);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  padding: 7px 13px;
  cursor: pointer;
  font: inherit;
  font-size: 12.5px;
  transition: color 120ms ease, border-color 120ms ease;
}
footer button:hover { color: var(--text); border-color: var(--line-strong); }

/* ──────────── Admin: Users view ──────────── */
#users-view .users-add {
  display: flex; gap: 10px; align-items: center; flex-wrap: wrap;
  margin: 0 0 14px;
}
#users-view .users-add input,
#users-view .users-add select,
#users-view .users-edit-name,
#users-view .users-edit-role {
  padding: 8px 10px; border: 1px solid var(--line); border-radius: var(--radius-sm);
  background: var(--panel); color: var(--text); font: inherit; min-width: 200px;
}
#users-view .users-add select,
#users-view .users-edit-role { min-width: 120px; }
/* Custom caret + remove the native select chrome so the role dropdown
   reads as part of the app rather than a browser-skinned <select>. Two
   5×5 gradient boxes paint a chevron-down: left box fills the
   bottom-right triangle, right box fills the bottom-left triangle, and
   they meet at the bottom centre to form the "v". */
#users-view .users-add select,
#users-view .users-edit-role {
  appearance: none;
  -webkit-appearance: none;
  -moz-appearance: none;
  background-image: linear-gradient(-45deg, transparent 50%, currentColor 50%),
                    linear-gradient(45deg, currentColor 50%, transparent 50%);
  background-position: calc(100% - 16px) 50%, calc(100% - 11px) 50%;
  background-size: 5px 5px, 5px 5px;
  background-repeat: no-repeat;
  padding-right: 28px;
  cursor: pointer;
}
#users-view .users-add input:focus,
#users-view .users-add select:focus,
#users-view .users-edit-name:focus,
#users-view .users-edit-role:focus {
  outline: none;
  border-color: var(--primary);
  box-shadow: 0 0 0 3px var(--primary-soft);
}
#users-view .users-edit-name { min-width: 160px; width: 100%; }
#users-view .users-edit-role option {
  /* Native option list uses the OS palette — pin it to the panel
     colour so the dropdown matches both themes when opened. */
  background: var(--panel);
  color: var(--text);
}
#users-view .users-error { margin: 0 0 14px; }
#users-view .users-external-hint {
  margin: 0 0 14px;
  padding: 10px 12px;
  border-radius: var(--radius-sm);
  border: 1px solid color-mix(in srgb, var(--warn) 38%, transparent);
  background: color-mix(in srgb, var(--warn) 10%, transparent);
  color: var(--text);
  font-size: 12.5px;
  line-height: 1.4;
}
#users-view .users-external-hint strong { color: var(--warn); }
#users-view .users-role { font-size: 11px; padding: 2px 7px; border-radius: var(--radius-pill); font-weight: 600; }
#users-view .users-role-admin { background: var(--primary-soft); color: var(--primary); }
#users-view .users-role-user { background: var(--panel-strong, var(--panel)); color: var(--muted); }
#users-view .users-scope { font-size: 11px; padding: 2px 7px; border-radius: var(--radius-pill); font-weight: 600; cursor: help; }
/* Internal is the wide scope, so it reads as the neutral default rather than as a
   privilege — most rows are internal and a loud pill on all of them would make
   the genuinely notable ones (operator, not set) harder to spot. */
#users-view .users-scope-internal { background: color-mix(in srgb, var(--muted) 16%, transparent); color: var(--muted); }
#users-view .users-scope-operator { background: color-mix(in srgb, var(--accent, var(--primary)) 18%, transparent); color: var(--text); }
/* A row the server REFUSES at sign-in. Marked as a fault, because it is one —
   the least notable pill on the row would be exactly backwards for a user who
   cannot log in. The token is --error, not --danger (which does not exist; an
   undefined var made color-mix invalid and dropped the pill entirely). */
#users-view .users-scope-none { background: color-mix(in srgb, var(--error) 20%, transparent); color: var(--error); }
#users-view .users-scope-warn {
  margin-left: 6px;
  font-size: 11px;
  padding: 2px 7px;
  border-radius: var(--radius-pill);
  font-weight: 600;
  background: color-mix(in srgb, var(--warn) 20%, transparent);
  color: var(--warn);
  cursor: help;
}
#users-view .users-actions { display: flex; gap: 6px; justify-content: flex-end; }
#users-view .users-actions-col { width: 1%; white-space: nowrap; }
#users-view table { width: 100%; border-collapse: collapse; }
#users-view th, #users-view td { padding: 8px 10px; text-align: left; border-bottom: 1px solid var(--line); }
#users-view th { font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); }

/* Addresses a user also answers to, shown under their primary in the Email
   cell. Present only after a merge or an automatic link, so an untouched user
   list looks exactly as it did. */
.users-alias-list {
  font-size: 12px;
  margin-top: 2px;
}
.users-alias {
  /* A dotted underline rather than a pill: these sit inside a table cell, and a
     pill per address would out-weigh the primary address above them. The
     underline is also the hint that there is a title worth hovering. */
  border-bottom: 1px dotted var(--line-strong);
  cursor: help;
}

/* Suspected duplicates — one person, two rows. A block above the table rather
   than a marker on each row: the point is to compare the two addresses side by
   side and pick one, which a highlight spread across two table rows cannot do. */
.users-duplicates {
  border: 1px solid var(--warn);
  border-radius: var(--radius-sm);
  background: color-mix(in srgb, var(--warn) 8%, transparent);
  padding: 12px 14px;
  margin: 0 0 14px;
  display: flex;
  flex-direction: column;
  gap: 10px;
}
/* `display: flex` above out-ranks the user-agent's `[hidden] { display: none }`,
   so the empty banner painted as a bare orange bar on every Users page — and on
   first paint even when there ARE duplicates, because the element ships hidden
   and is only filled once /v1/users answers. Same fix as .bd-route-line[hidden]
   further down: any rule that sets `display` on an element that also toggles
   `hidden` has to restore it. */
.users-duplicates[hidden] { display: none; }
.users-duplicates-head {
  margin: 0;
  font-size: 13px;
}
.users-dup-group {
  display: flex;
  flex-direction: column;
  gap: 4px;
}
.users-dup-name {
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: 0.12em;
  font-weight: 600;
  color: var(--muted);
}
.users-dup-row {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
}
/* The address is what is being compared, so it takes the flexible width and the
   role/provenance/button shrink to their content. */
.users-dup-email {
  flex: 1 1 auto;
  min-width: 200px;
  font-size: 13px;
}
.users-dup-by { font-size: 12px; }



/* "Viewing as operator" banner. Deliberately loud: an internal admin who forgets
   they are inside someone else's scope will misread every number on the page, so
   this is the one piece of chrome that should not be subtle. Uses --warn rather
   than --danger — it is a state, not a fault. */
.view-as-banner {
  display: flex;
  align-items: center;
  gap: 12px;
  flex-wrap: wrap;
  padding: 8px 16px;
  background: color-mix(in srgb, var(--warn) 18%, transparent);
  border-bottom: 1px solid var(--warn);
  font-size: 13px;
}
/* An author `display` beats the UA [hidden] rule — the same trap that painted an
   empty orange bar on the Users page. */
.view-as-banner[hidden] { display: none; }
.view-as-label { font-weight: 600; }
.view-as-note { font-size: 12px; }

/* The account-menu picker. Matches the dashboard filter bar's
   .group-multi-trigger, NOT .users-add's form controls — it is the same kind of
   control doing the same kind of job (choose whose fleet you are looking at), and
   the two read as different widgets side by side.

   It used to match .users-add on the reasoning that it "sits in the popover with
   the other preference controls". That reasoning does not hold: the other controls
   in this popover are segmented .seg button groups, so there is no sibling select
   for it to be consistent with, and the only thing it resembles is the filter bar.

   Every value below is taken from .group-multi-trigger. If that changes, change
   this with it. */
.user-menu-select-wrap { position: relative; }
.user-menu-select {
  width: 100%;
  background: var(--panel);
  color: var(--text);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  padding: 4px 10px;
  /* Room for the caret, which sits 10px from the right edge and is 14px wide. */
  padding-right: 32px;
  min-height: var(--control-h);
  font: inherit;
  font-size: 13px;
  cursor: pointer;
  transition: border-color 120ms ease, box-shadow 120ms ease;
  /* The UA arrow goes, so the caret below is the only one. */
  appearance: none;
  -webkit-appearance: none;
}
.user-menu-select:hover { border-color: var(--line-strong); }
/* The trigger shows this ring on [aria-expanded="true"]; a native select has no
   such state, so the nearest equivalent is focus. :focus-visible rather than
   :focus so it does not ring on a mouse click, which the button never does. */
.user-menu-select:focus-visible {
  outline: none;
  border-color: var(--primary);
  box-shadow: 0 0 0 3px var(--primary-soft);
}
/* The SAME chevron as .group-multi-caret — polyline 6 9 / 12 15 / 18 9, 2px round
   stroke, 14px, 0.7 opacity. Masked, not a background image, so it inherits
   `currentColor` and tracks the theme instead of hardcoding a colour per theme.
   `data:` is permitted by img-src in staticwebapp.config.json, which is what
   governs mask-image. */
.user-menu-select-wrap::after {
  content: '';
  position: absolute;
  top: 50%;
  /* 11px, not 10px: the trigger's inline caret sits inside the padding box, so it
     clears the 1px border as well as the 10px padding. Measured, not assumed —
     at 10px the two caps sat one pixel apart. */
  right: 11px;
  width: 14px;
  height: 14px;
  transform: translateY(-50%);
  background-color: currentColor;
  opacity: 0.7;
  pointer-events: none;
  -webkit-mask: var(--caret-mask) center / 14px 14px no-repeat;
  mask: var(--caret-mask) center / 14px 14px no-repeat;
}

/* ── Push API: bus-detail panel ─────────────────────────────────── */

/* Live route, rendered as PLAIN TEXT in both places rather than a pill.
   On the dashboard panel it sits under the type line and matches it exactly
   (.muted); on the vehicle page it is an ordinary "Route" row in the identity
   list and inherits .vh-val. A pill promised more prominence than a route
   number needs, and read as a different class of thing from the fleet and job
   numbers beside it. */
.bd-route-line {
  margin-top: 2px;
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* Matched a route, but no shape we can draw. The appended note carries the
   meaning now that there is no colour to lose, so this only needs to stay
   within the muted treatment both surfaces already use. */
.bd-route-line.is-unmapped,
#vh-route.is-unmapped { color: var(--muted); }

/* The old pill set display:inline-block, which beat the UA [hidden] rule; the
   plain-text line inherits block display, but keep this explicit so a future
   display change cannot resurrect a hidden route. */
.bd-route-line[hidden] { display: none; }

/* Bus-detail push panel — sits between battery and recent events.
   Tighter than the dashboard feed because the panel is narrower. */
#bd-push-section {
  margin-top: 8px;
}
.bd-push-list {
  display: flex;
  flex-direction: column;
  gap: 3px;
  font-size: 13px;
}
.bd-push-row {
  display: grid;
  grid-template-columns: minmax(110px, 1.4fr) minmax(60px, 1fr) auto;
  align-items: center;
  gap: 10px;
  padding: 4px 0;
  border-bottom: 1px dashed var(--line);
}
.bd-push-row:last-child { border-bottom: none; }
.bd-push-name { color: var(--text); }
.bd-push-val { color: var(--primary); font-weight: 600; text-align: right; }
.bd-push-time { font-size: 12px; }

/* ──────────── Warranty & Asset (admin) ──────────── */
/* Summary chips. Clickable: a chip applies its matching filter. */
/* Says the chips above are fleet-wide totals. Sits tight under them so it
   reads as their caption, not as a page note. */
.warranty-summary-note {
  margin: -10px 0 14px;
  font-size: 12px;
}
/* A full-width row at the end of .report-controls, not a child of the
   Component state control — inside that control it made the flex item ~3 lines
   taller than its siblings, and .report-controls' `align-items: flex-end`
   pushed every control sharing its wrapped line below the Component state
   dropdown. flex-basis:100% puts it on its own line, so it sets no control's
   height.
   NO max-width, deliberately: flex line-breaking uses the item's hypothetical
   size, which a max-width CLAMPS — with a 92ch cap the item measured ~666px,
   fitted in the space left beside Live status and never took its own line at
   1800px. Unconstrained it is the full 100% basis, so it always wraps. It
   costs nothing in measure: the sentence's natural width is ~1000px, so it
   still ends well short of the right edge on an ultrawide. */
.warranty-facet-hint {
  flex-basis: 100%;
  margin: -2px 0 0;
  font-size: 11.5px;
  line-height: 1.35;
}
.warranty-summary {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  margin-bottom: 16px;
}
.warranty-stat {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 104px;
  padding: 10px 14px;
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  box-shadow: var(--shadow-card);
  cursor: pointer;
  text-align: left;
  color: var(--text);
  transition: border-color 120ms ease, background 120ms ease;
}
.warranty-stat:hover { border-color: var(--line-strong); }
.warranty-stat.is-active { border-color: var(--primary); background: var(--primary-soft); }
.warranty-stat-val { font-size: 22px; font-weight: 700; line-height: 1.1; }
.warranty-stat-lbl {
  font-size: 12px;
  color: var(--muted);
  display: inline-flex;
  align-items: center;
  gap: 6px;
}
.warranty-dot { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; }
.warranty-dot-expiring { background: var(--warn); }
.warranty-dot-out      { background: var(--error); }
.warranty-dot-unknown  { background: var(--hb-slate); }

/* Warranty status pill (table cell + drawer). */
.warranty-badge {
  display: inline-block;
  padding: 1px 8px;
  border-radius: var(--radius-pill);
  font-size: 11.5px;
  font-weight: 600;
  white-space: nowrap;
}
.warranty-badge-in       { background: rgba(10,193,53,0.16);  color: var(--good); }
.warranty-badge-expiring { background: rgba(249,133,19,0.16); color: var(--warn); }
.warranty-badge-out      { background: rgba(255,107,107,0.16); color: var(--error); }
.warranty-badge-unknown  { background: rgba(88,116,129,0.18); color: var(--muted); }
/* Not a warranty status: "no term is recorded at all" (warranty map panel).
   The neutral outlined-chip recipe .fault-chip-code already uses, so it reads
   as a chip but never as one of the four statuses. Both mixes are off --muted
   rather than --line-strong: at 0.22 alpha that token vanishes on --panel-2,
   which is the ground this badge actually sits on. */
.warranty-badge-none {
  background: color-mix(in srgb, var(--muted) 14%, transparent);
  color: var(--muted);
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--muted) 26%, transparent);
}

/* Live-status text in the Seen column / drawer. */
.warranty-live-live    { color: var(--good);  font-weight: 600; }
.warranty-live-online  { color: var(--warn);  font-weight: 600; }
.warranty-live-offline { color: var(--muted); }

.warranty-estimate-note { color: var(--warn); margin-top: -4px; }
#warranty-status-msg:empty, #wr-status:empty { display: none; }

/* Fixed layout + percentage widths so 12 columns stay aligned and in
   view; every value ellipsizes with the full text in the title attr —
   the same approach as #report-table / #telematics-table. */
#warranty-table { table-layout: fixed; }
/* Widths sum to 100% and are tuned against the real content width (main caps
   at 1280px, so ~1232px across the 14 columns). Defects/Mods/Live only ever
   show a small count, badge or "—", so they stay narrow. Cover is narrow BY
   DESIGN: it renders a badge plus one count ("Out · 2") and keeps the full
   breakdown in its title, because a fixed-layout table that must fit the page
   cannot afford ~160px for four "N label" phrases — that allocation ellipsised
   the Opportunity, Operator and Drv HEADERS and cut the odometer to "184…",
   and still cut its own breakdown mid-word. Opportunity, Operator, Drv, Odo,
   Live, Defects and Mods are therefore back at exactly their pre-component
   widths. Cover needs 12%, because its content is a badge — an inline chip
   that cannot ellipsize gracefully — plus the "· N": at 8% the longest badges
   lost their count entirely ("Expiring ·…") and the unmatched chip clipped
   mid-phrase. Terms needs 7%, not the ~40px its label suggests: a sortable th
   reserves 22px on the right for the ▲/▼ indicator, so at 6% the word itself
   ellipsised. Both come out of VIN, which is the one long value nobody reads
   in full from a table — it is always ellipsised, and the whole VIN is in the
   cell's title and in the CSV export.
   Fleet/Job/Reg are short (current number, build code, plate). */
#warranty-table th:nth-child(1),  #warranty-table td:nth-child(1)  { width:  6%; } /* Fleet */
#warranty-table th:nth-child(2),  #warranty-table td:nth-child(2)  { width:  6%; } /* Job */
#warranty-table th:nth-child(3),  #warranty-table td:nth-child(3)  { width: 11%; } /* Opportunity */
#warranty-table th:nth-child(4),  #warranty-table td:nth-child(4)  { width:  6%; } /* Reg */
#warranty-table th:nth-child(5),  #warranty-table td:nth-child(5)  { width:  6%; } /* VIN */
#warranty-table th:nth-child(6),  #warranty-table td:nth-child(6)  { width:  9%; } /* Operator */
#warranty-table th:nth-child(7),  #warranty-table td:nth-child(7)  { width:  6%; } /* Drv */
#warranty-table th:nth-child(8),  #warranty-table td:nth-child(8)  { width: 12%; } /* Cover */
#warranty-table th:nth-child(9),  #warranty-table td:nth-child(9)  { width: 10%; } /* Next expiry */
#warranty-table th:nth-child(10), #warranty-table td:nth-child(10) { width:  7%; } /* Terms */
#warranty-table th:nth-child(11), #warranty-table td:nth-child(11) { width:  6%; } /* Odo */
#warranty-table th:nth-child(12), #warranty-table td:nth-child(12) { width:  5%; } /* Live */
#warranty-table th:nth-child(13), #warranty-table td:nth-child(13) { width:  5%; } /* Defects */
#warranty-table th:nth-child(14), #warranty-table td:nth-child(14) { width:  5%; } /* Mods */

/* Breakpoint-scoped rebalance: only from 1500px, where the stepped max-width
   above widens the table (~1672px at 1800px+). Every header then fits EXCEPT
   "Defects" — 5% of 1672px is ~84px, and the sortable ▲/▼ indicator reserves
   22px of the cell, leaving ~48px against the ~58px the word needs. Scoping it
   to this query keeps the narrow/normal tuning above untouched: at 1280px 6%
   is already what VIN needs to show a usable prefix, and taking a point off it
   there would cost more than the header gains. The point comes from VIN for
   the same reason it did above — VIN always ellipsises anyway, and the full
   value is in the cell's title and in the CSV export. Widths still total 100. */
@media (min-width: 1500px) {
  #warranty-table th:nth-child(5),  #warranty-table td:nth-child(5)  { width:  5%; } /* VIN */
  #warranty-table th:nth-child(13), #warranty-table td:nth-child(13) { width:  6%; } /* Defects */
}

/* Shared by both warranty tables: ellipsize, right-align numerics,
   keep the em-dash placeholder visible. The reports table (#wr-table)
   has dynamic columns so it stays auto-layout — only the cell rules
   below apply to it. */
.warranty-table td {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.warranty-table td code {
  display: inline-block;
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  vertical-align: bottom;
}
.warranty-table th.num, .warranty-table td.num { text-align: right; }
/* Cover is a badge plus the worst bucket's count ("Out · 2") — see the width
   block above for why it is not the full breakdown. It must stay on ONE line
   (a wrapping cell would set the height of the whole row), so it ellipsizes
   with the full breakdown in the title attr rather than reflowing. The badge is
   inline-block, so no flex box is involved and the standard overflow/ellipsis
   rule on .warranty-table td does the truncation. */
.warranty-table td.w-cover .muted { font-size: 11.5px; }
.warranty-table td .muted { color: var(--muted); }
.warranty-table tbody tr { cursor: pointer; }
/* The reports table has no drawer to open — only the list rows are clickable. */
#wr-table tbody tr { cursor: default; }

/* Fleet coverage matrix. It is an EXPOSURE report, so the eye has to land on
   the gaps: a zero is the non-event (and in Out / Not recorded it is the good
   answer) and recedes to --muted, while a non-zero gap takes the same hue as
   the matching .warranty-badge-* so a count and a badge never disagree about
   what colour "out" is. Tint, not background fill — a grid of coloured blocks
   would flatten the difference between 1 and 400. */
#wr-table td.wr-zero { color: var(--muted); }
#wr-table td.wr-gap-out { color: var(--error); font-weight: 700; }
#wr-table td.wr-gap-missing { color: var(--warn); font-weight: 700; }
/* Numeric alignment, restated on the ID. #wr-table already right-aligns its
   .num cells through the shared `.warranty-table th.num, td.num` rule above —
   it carries that class — and measurement confirms it always has; the reports'
   numeric columns were never left-aligned. It is pinned here anyway because
   #wr-table's only structural reason to wear `warranty-table` is these cell
   rules (its columns are dynamic, so it deliberately skips the list table's
   fixed widths), and dropping the class would silently un-align 20 numeric
   report columns. Header and cell together, or the digits and their label fall
   out of line. */
#wr-table th.num,
#wr-table td.num { text-align: right; font-variant-numeric: tabular-nums; }

/* Scope pickers on a report that cannot be scoped. Dimmed and inert rather
   than removed: "does not apply here" is information, "not offered" is not.
   The note itself is a warning, not an error — nothing has gone wrong. */
.report-control.is-disabled { opacity: 0.5; }
.report-control.is-disabled .group-multi-trigger { cursor: not-allowed; }
.wr-scope-note { color: var(--warn); margin-top: -4px; }

/* Active/Previous Campaign lists in the drawer reuse .bd-events; give
   each line a small status chip. */
.wd-line { display: flex; justify-content: space-between; gap: 8px; }
.wd-line .wd-line-status { color: var(--muted); font-size: 11.5px; white-space: nowrap; }

/* ──────────── Vehicle page ──────────── */
/* List state: one table, with sorting and per-column filters in its header. */
.vehicle-list-table { width: 100%; table-layout: fixed; }
/* Body cells truncate — a VIN is 17 characters and the column is never that
   wide. Headers do NOT: a clipped column name is the one piece of text on the
   table a reader cannot recover by hovering a row, so they wrap instead. */
.vehicle-list-table td {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.vehicle-list-table th { vertical-align: bottom; }
.vehicle-list-table.sortable th[data-sort-key] .th-label {
  white-space: normal;
  overflow: visible;
  max-width: none;
}
/* Widths by CLASS, not nth-child: operator scope hides the two internal-identity
   columns, and a positional rule would slide every width after them onto the
   wrong column. Sums to 100% internally; with the two hidden, the browser shares
   the freed 24% out proportionally. */
.vehicle-list-table .vl-col-fleet    { width: 11%; }
.vehicle-list-table .vl-col-job      { width: 10%; }
.vehicle-list-table .vl-col-opp      { width: 13%; }
.vehicle-list-table .vl-col-reg      { width: 10%; }
.vehicle-list-table .vl-col-vin      { width: 18%; }
.vehicle-list-table .vl-col-category { width: 15%; }
.vehicle-list-table .vl-col-bustype  { width: 13%; }
.vehicle-list-table .vl-col-live     { width: 10%; } /* 11+10+13+10+18+15+13+10 = 100 */
/* Filter row. <td> inside the thead, so none of the uppercase/sticky header
   styling lands on it; it takes the header background instead and keeps the
   rule under itself that `tr:last-child td` would otherwise remove. */
.vehicle-list-table .vl-filter-cell {
  background: var(--bg-deep);
  padding: 0 8px 8px;
  border-bottom: 1px solid var(--line);
}
.vehicle-list-table .vl-filter-input,
.vehicle-list-table .vl-filter-select {
  width: 100%;
  min-width: 0;
  padding: 3px 8px;
  min-height: 28px;
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  background: var(--panel);
  color: var(--text);
  font-size: 12.5px;
  font-family: inherit;
  outline: none;
}
.vehicle-list-table .vl-filter-input:focus,
.vehicle-list-table .vl-filter-select:focus { border-color: var(--primary); }
.vehicle-list-table td code { max-width: 100%; overflow: hidden; text-overflow: ellipsis; display: inline-block; vertical-align: bottom; }
.vehicle-list-count { margin-top: 8px; font-size: 12px; }
.vehicle-empty { padding: 8px 2px; }
/* Prev/Next pager for the /vehicle list. */
.pager { display: flex; align-items: center; justify-content: center; gap: 12px; margin-top: 12px; }
.pager-btn { padding: 6px 12px; }
.pager-btn:disabled { opacity: 0.45; cursor: default; }
.pager-label { min-width: 96px; text-align: center; font-size: 12px; }
.vehicle-list-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.vehicle-list-footer .pager { margin-top: 8px; }

/* Detail state */
.vh-back {
  background: none;
  border: none;
  color: var(--primary);
  cursor: pointer;
  font-size: 13px;
  font-weight: 600;
  padding: 0;
  margin-bottom: 12px;
}
.vh-back:hover { text-decoration: underline; }
/* 4-column grid. The column boxes are all the same fixed height. Row 1 = Bus
   details · Position (2 cols) · Live telemetry; row 2+ = Warranty, Active
   Campaigns, Previous Campaigns (each a single box that scrolls if its list is
   long). --vh-card-h is the single knob — set tall enough to fit the
   Live-telemetry box's rows (a fixed single-line set) so it never scrolls.

   The uniform height lives on the CARD, not on the row track (`grid-auto-rows`
   stays `auto`). That distinction matters: a fixed row track forces every item
   in it to that height, so a full-width card with only a little content — the
   VOR card with one fault — was stretched to 470px and grew a dead band between
   its rows and its footer. With auto tracks, a row of standard cards is still
   exactly --vh-card-h, while a full-width card can opt out with `height: auto`
   and get a row that fits its content (see .vh-card-vor / .vh-card-wmap). */
.vehicle-grid {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  --vh-card-h: 470px;
  grid-auto-rows: auto;
  gap: 12px;
  align-items: stretch;
}
.vh-card {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  box-shadow: var(--shadow-card);
  padding: 14px 16px;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  height: var(--vh-card-h);
}
/* `display: flex` above would otherwise override the `hidden` attribute the
   admin-only Warranty card uses (renderVehicle sets wcard.hidden for non-admins). */
.vh-card[hidden] { display: none; }
.vh-card > h3 { font-size: 14px; font-weight: 600; margin: 0 0 10px; display: flex; align-items: center; justify-content: space-between; gap: 8px; flex: 0 0 auto; }
/* Live badge sits to the right of the "Live telemetry" heading, chip-sized. */
.vh-live-status { font-size: 11px; font-weight: 600; }
.vh-card-map { grid-column: span 2; } /* double-width Position box */
/* Map fills the card between its heading and the GPS line. */
.vehicle-map { flex: 1 1 auto; min-height: 0; border-radius: var(--radius-sm); overflow: hidden; }
.vh-gps-meta { margin-top: 8px; font-size: 12px; flex: 0 0 auto; }
/* Card bodies fill the remaining height and scroll within the uniform box
   height (defects / modifications lists in particular). scrollbar-gutter keeps
   the scrollbar in reserved space to the right of the content rather than
   overlapping it. */
.vh-rows, #vh-live, .vh-card > .bd-events { flex: 1 1 auto; min-height: 0; overflow: auto; scrollbar-gutter: stable; }
.vh-rows { display: flex; flex-direction: column; gap: 6px; }
.vh-row { display: flex; justify-content: space-between; gap: 12px; font-size: 13px; }
.vh-row .vh-label { color: var(--muted); flex: 0 0 auto; }
.vh-row .vh-val { text-align: right; word-break: break-word; }
/* Live-telemetry card reuses the dashboard slideover's .bd-* rows. */
#vh-live { display: flex; flex-direction: column; gap: 8px; }
#vh-live .bd-section-head:first-child { margin-top: 0; }
/* Keep every telemetry row at its natural size — without this the contentless
   SoC bar (the only zero-text child) gets flex-shrunk to nothing in the
   fixed-height column, so the progress bar disappears. */
#vh-live > * { flex-shrink: 0; }
/* Keep each telemetry value on one line in this narrow column; if a row is
   ever tight the (short) label yields/ellipsises rather than the value
   wrapping mid-number. */
#vh-live .bd-val { white-space: nowrap; flex: 0 0 auto; }
#vh-live .bd-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* ──────────── On-board software (vehicle page, internal admin only) ────────
   Four label/value rows reusing .vh-rows, so the card reads as the same kind of
   thing as the Bus details card rather than as a new widget.

   `height: auto` for the same reason as .vh-card-vor: it carries four rows and
   would otherwise be stretched to the full --vh-card-h with a dead band beneath
   them. It still stretches when it shares a row with a standard-height card,
   which is correct — a ragged row edge would read as a rendering fault. */
.vh-card-software { height: auto; }
/* Versions are identifiers, not prose: tabular figures so 0.8.2 and 0.1.5 line
   up down the column, and no mid-number wrap. */
.vh-card-software .vh-val { font-variant-numeric: tabular-nums; white-space: nowrap; }

/* ──────────── Journeys (vehicle page, internal admin only) ────────────
   A list and a map that have to be read together: pick a journey on the left,
   see the line on the right. Full row, because a route drawn into a quarter-
   width box is a squiggle rather than a route.

   `height: auto` with a max, like .vh-card-vor: a day with two journeys must not
   be stretched to a tall empty card, and a day with twenty must not run off the
   page. */
.vh-card-journeys {
  grid-column: 1 / -1;
  height: auto;
  max-height: calc(var(--vh-card-h) * 1.4);
}
.vhj-head-right { display: flex; align-items: center; gap: 8px; margin-left: auto; }
.vhj-date-label { font-size: 12px; color: var(--muted); font-weight: 400; }
.vhj-date {
  font: inherit;
  font-size: 13px;
  padding: 3px 6px;
  color: var(--text);
  background: var(--bg);
  border: 1px solid var(--line);
  border-radius: 6px;
}
.vhj-csv { font-size: 12px; padding: 3px 10px; }
.vhj-summary { font-size: 12px; margin: 2px 0 8px; }
/* The caveats — a partial day, a day before capture — sit ABOVE the list, in the
   reading path, not under it where a scrolled list would hide them. */
.vhj-note {
  font-size: 12px;
  padding: 6px 10px;
  border-radius: 6px;
  margin-bottom: 8px;
  background: color-mix(in srgb, var(--primary) 10%, transparent);
  border: 1px solid color-mix(in srgb, var(--primary) 30%, transparent);
}
.vhj-note.warn {
  background: color-mix(in srgb, var(--warn) 12%, transparent);
  border-color: color-mix(in srgb, var(--warn) 34%, transparent);
}
.vhj-body {
  display: grid;
  grid-template-columns: minmax(280px, 2fr) 3fr;
  gap: 12px;
  flex: 1;
  min-height: 0;
}
/* `min-width: 0` as well as min-height: a grid item defaults to min-content
   width, so without it the table's natural width pushes the whole card wider
   than the screen instead of scrolling inside its own column. */
.vhj-list-wrap { overflow: auto; min-height: 0; min-width: 0; }
/* `min-width: 0` undoes the bare-element `table { min-width: 720px }` in the
   data-table block above, which otherwise forces this table 720px wide inside a
   ~470px column and pushes Distance and Max off the edge behind a scrollbar.
   Same fix, same reason, as `.heyhy-root .heyhy-table-wrap table`. */
.vhj-table { width: 100%; min-width: 0; border-collapse: collapse; font-size: 13px; }
.vhj-table th {
  text-align: left;
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: .04em;
  color: var(--muted);
  font-weight: 600;
  padding: 0 8px 6px 0;
  position: sticky;
  top: 0;
  background: var(--panel);
}
/* A little air on the right: the scroll bar sits over the last column, and a
   figure touching it reads as clipped. */
.vhj-table th.num, .vhj-table td.num { text-align: right; padding-right: 6px; }
/* border-bottom: 0 for the same reason — the bare `th, td` rule gives every
   cell a bottom border, which together with this top one double-rules each row. */
.vhj-table td { padding: 6px 8px 6px 0; border-top: 1px solid var(--line); border-bottom: 0; }
/* Figures line up down the column; a journey list is read by comparing rows. */
.vhj-table td.num, .vhj-table td.vhj-time { font-variant-numeric: tabular-nums; white-space: nowrap; }
/* A visible focus ring: the rows are keyboard-operable, and without this the
   only feedback is the selection tint, which lands on Enter rather than on
   arrival. */
.vhj-row { cursor: pointer; }
.vhj-row:focus-visible { outline: 2px solid var(--primary); outline-offset: -2px; }
.vhj-row:hover td { background: color-mix(in srgb, var(--primary) 7%, transparent); }
.vhj-row.is-selected td { background: color-mix(in srgb, var(--primary) 14%, transparent); }
.vhj-row.is-selected td:first-child { box-shadow: inset 3px 0 0 var(--primary); }
.vhj-gap { display: block; font-size: 11px; color: var(--warn); }
.vhj-map { min-height: 260px; min-width: 0; border-radius: 8px; overflow: hidden; }
/* Nothing to show: the card collapses to its header and the reason. A full-height
   empty table beside an empty map reads as a broken card rather than an answer. */
.vhj-body[hidden] { display: none; }
.vh-card-journeys:has(.vhj-body[hidden]) { max-height: none; }

/* The header carries a date picker and an export button; on a narrow card they
   wrap under the title rather than pushing the card wider than the screen. */
.vh-card-journeys h3 { flex-wrap: wrap; row-gap: 6px; }

/* One column on narrow screens: the map keeps its height, the list scrolls. */
@media (max-width: 900px) {
  .vhj-body { grid-template-columns: 1fr; }
  .vhj-map { min-height: 220px; }
  .vh-card-journeys { max-height: none; }
}
/* Measured at a 500px viewport: all four columns fit inside the card (table
   419px in a 419px column, no page overflow), so nothing is dropped on a phone.
   The header controls wrap instead. */
@media (max-width: 620px) {
  .vhj-table td, .vhj-table th { padding-right: 4px; }
  .vhj-head-right { margin-left: 0; }
}

/* ──────────── Warranty map (vehicle page, admin-only) ────────────
   Schematic bus with one box per Salesforce component term. Every colour is a
   theme token or a token-derived tint — the diagram has to read correctly in
   BOTH themes, so nothing here may be a hardcoded hex. The box tints reuse the
   exact values of .warranty-badge-* above, which is what makes a box and its
   badge in the detail panel read as the same status. */
/* Full row: the diagram needs the width, and the detail panel sits beneath it
   inside the same card. Content-sized like the VOR card (the SVG is capped at
   900px wide, so the card is around 1.5 standard cards tall) rather than
   spanning a fixed number of row tracks. */
.vh-card-wmap { grid-column: 1 / -1; height: auto; }
.wmap-hint { font-size: 11px; font-weight: 400; }
.wmap-head-right { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; justify-content: flex-end; }
.wmap-body { flex: 1 1 auto; min-height: 0; overflow: auto; scrollbar-gutter: stable; }
/* Card-level caveat strip above the diagram (shown only when the vehicle has no
   matched Salesforce Asset). Deliberately reads as a note, not as data: muted
   text on the inset --panel-2 ground (--panel is the .vh-card background it
   sits on, so it would have no contrast at all) with a warning rule down the
   leading edge, and no badge geometry that would let it be mistaken for a
   status. Same ground as .wmap-panel below. */
.wmap-note {
  font-size: 12.5px;
  color: var(--muted);
  margin: 0 auto 14px;
  max-width: 900px;
  padding: 8px 12px;
  border-left: 3px solid var(--warn);
  border-radius: var(--radius-sm);
  background: var(--panel-2);
}
.wmap-note[hidden] { display: none; }
.wmap-figure { margin: 0 auto; max-width: 900px; }
.wmap-figure svg { width: 100%; height: auto; display: block; }
.wmap-figure figcaption { color: var(--muted); font-size: 11.5px; margin-top: 8px; }

/* SVG text: inherit the page font family AND colour rather than declaring
   either (no `font:` shorthand — it would force a family). */
.wmap-lbl { font-weight: 600; font-size: 12px; fill: currentColor; }
.wmap-sml { font-weight: 400; font-size: 10.5px; fill: var(--muted); }
.wmap-axis { font-weight: 600; font-size: 11px; fill: var(--muted); }
.wmap-plus { font-weight: 700; font-size: 15px; }
.wmap-outline { fill: none; stroke: var(--line-strong); stroke-width: 2.5; }
.wmap-axle { fill: var(--muted); }
.wmap-tie { fill: none; stroke: var(--line-strong); stroke-width: 1.5; stroke-dasharray: 5 4; }
.wmap-bx { stroke-width: 1.5; }
.wmap-bx.wmap-dash { stroke-dasharray: 5 4; }


/* Status colour. One custom property per status keeps the box and its "+" in
   agreement. The 16% tint is the same strength as .warranty-badge-*, but mixed
   with the card background rather than being translucent: a box may sit ON TOP
   of the bus outline (the rear-axle box does, deliberately), and a see-through
   fill would let the geometry beneath strike through its label. */
.wmap-in { --wmap-status: var(--good); }
.wmap-expiring { --wmap-status: var(--warn); }
.wmap-out { --wmap-status: var(--error); }
.wmap-unknown { --wmap-status: var(--muted); }
/* "Not recorded" — no Salesforce term at all. It must not be confusable with
   .wmap-unknown, which means a term IS recorded but could not be parsed or
   dated. Both take the muted token (--line-strong is 0.22-alpha: as a 1.5px
   dashed stroke and as the "+" fill it would be all but invisible), so the two
   are told apart by FILL: unknown keeps a tint, this is drawn on the bare card
   ground with the dashed outline .wmap-dash gives it and a lighter label. */
.wmap-none { --wmap-status: var(--muted); }
.wmap-box .wmap-bx { fill: color-mix(in srgb, var(--wmap-status) 16%, var(--panel)); stroke: var(--wmap-status); }
.wmap-unknown .wmap-bx { fill: color-mix(in srgb, var(--wmap-status) 20%, var(--panel)); }
.wmap-none .wmap-bx { fill: var(--panel); }
.wmap-none .wmap-lbl { fill: var(--muted); font-weight: 500; }
.wmap-box .wmap-plus { fill: var(--wmap-status); }

/* Selection: the chosen box stays lit, the rest dim (the odos .has-sel pattern). */
.wmap-box { cursor: pointer; transition: opacity 200ms ease; }
.wmap-box .wmap-plus { opacity: 0.55; }
.wmap-box:hover .wmap-plus, .wmap-box.is-sel .wmap-plus { opacity: 1; }
.wmap-box:hover .wmap-bx { stroke-width: 3; }
.wmap-box:focus { outline: none; }
.wmap-box:focus-visible .wmap-bx { stroke-width: 3.5; stroke-dasharray: none; }
.wmap-svg.has-sel .wmap-box { opacity: 0.3; }
.wmap-svg.has-sel .wmap-box.is-sel { opacity: 1; }
.wmap-box.is-sel .wmap-bx { stroke-width: 3.5; }
@media (prefers-reduced-motion: reduce) {
  .wmap-box { transition: none; }
}

/* Detail panel for the selected component. */
.wmap-panel {
  margin: 14px auto 0;
  max-width: 900px;
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  background: var(--panel-2);
  padding: 12px 14px;
}
.wmap-panel[hidden] { display: none; }
.wmap-panel-head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.wmap-panel-title { font-size: 13px; font-weight: 600; }
/* Compact like .btn-sm (it sits in a panel header beside a 13px title, not on
   an action row), so it opts out of --control-h the same way .btn-sm does. */
.wmap-close { margin-left: auto; padding: 4px 10px; min-height: 0; font-size: 12px; }
.wmap-panel-rows { gap: 6px; }
/* Placeholder panel: prose in place of the term rows. The second line is the
   qualifier that stops "not recorded" being read as "not covered". */
.wmap-panel-none { font-size: 12.5px; display: flex; flex-direction: column; gap: 6px; }
.wmap-panel-none .muted { font-size: 11.5px; }

/* Narrower screens: 2 columns (map stays double-width), then a single column. */
@media (max-width: 1080px) {
  .vehicle-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  /* Map drops to a single column here — keeping span 2 after the 1-col Bus
     details card would leave an empty cell (the span can't fit row 1 col 2). */
  .vh-card-map { grid-column: span 1; }
}
@media (max-width: 620px) {
  /* Single column; the height still comes from .vh-card, not the row track. */
  .vehicle-grid { grid-template-columns: 1fr; }
  .vh-card-map { grid-column: span 1; }
}
.vh-fullpage-link {
  display: inline-block;
  margin-bottom: 12px;
  padding: 0;
  background: none;
  border: none;
  color: var(--primary);
  cursor: pointer;
  font-size: 12px;
  font-weight: 600;
}
.vh-fullpage-link:hover { text-decoration: underline; }

/* ──────────────── Faults & health report (push-derived) ──────────────── */
.fault-summary {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
  margin-bottom: 22px;
}
/* Same surface as .card (the dashboard topcards): --panel, a 1px --line
   border, --radius, --shadow-card and the --line-strong hover. These tiles are
   the Faults and Battery-health equivalent of those cards, so they are built
   from the same parts rather than from a bespoke shadow-less, left-barred
   treatment that read as a different product. Every tile in both views is
   emitted by faultStatTileHtml() in app.js — including the "No active faults"
   collapse, which used to hand-roll its own copy of this markup — so they stay
   in step by construction. */
.fault-stat {
  flex: 1 1 200px;
  min-width: 170px;
  display: flex;
  flex-direction: column;
  background: var(--fault-ground, var(--panel));
  border: 1px solid var(--fault-edge, var(--line));
  border-radius: var(--radius);
  box-shadow: var(--shadow-card);
  padding: 14px 16px;
  transition: border-color 120ms ease;
}
.fault-stat:hover { border-color: var(--line-strong); }
/* Severity. One custom property per state, exactly like .wmap-* above, so the
   ground tint and the border can never disagree. The left bar is gone on
   purpose: nothing else in the app signals severity with an edge rule — the
   warranty map boxes and the VOR surfaces all use a low-strength tint of a
   token plus a stronger edge of the same token, and a whole tinted card is a
   stronger signal here than a 3px rule.
   BOTH mixes take --panel, not --line or transparent, and that is the whole
   point: --line is rgba(…, 0.14), and color-mix interpolates alpha too, so
   mixing 38% of an opaque status into it lands at alpha ≈ 0.38 + 0.62×0.14 ≈
   0.47 and srgb premultiplication washes the hue out — the stated 38% would
   have meant nothing and the three states would have been near-identical.
   Against the opaque --panel the percentage means what it says, and the tiles
   sit on the page ground identically in both themes. */
.fault-stat.is-danger { --fault-status: var(--error); }
.fault-stat.is-warn   { --fault-status: var(--warn); }
.fault-stat.is-good   { --fault-status: var(--good); }
.fault-stat.is-danger,
.fault-stat.is-warn,
.fault-stat.is-good {
  --fault-ground: color-mix(in srgb, var(--fault-status) 10%, var(--panel));
  --fault-edge: color-mix(in srgb, var(--fault-status) 38%, var(--panel));
}
.fault-stat.is-danger:hover,
.fault-stat.is-warn:hover,
.fault-stat.is-good:hover { border-color: var(--fault-status); }
/* Label above the value, matching .card's <h3>-then-.big order. This is DOM
   order, not `order: -1`: the CSS-only version left a screen reader announcing
   "12, Buses with confirmed faults, 3 named · 9 warning" while a sighted user
   read label-first, and coupled faultStatTileHtml()'s child order to a rule
   three files away. The helper now emits label → num → sub directly. */
.fault-stat-label {
  margin-bottom: 6px;
  font-size: 10.5px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.12em;
  color: var(--muted);
}
/* 22px/600 tabular-nums is `.card .big` verbatim — the app's existing stat
   scale, not a fourth number size.
   --text, NOT --fault-status. A token on its own 10% tint fails badly in the
   light theme: --warn #F98513 on that ground measures 2.3:1 and --good #0AC135
   measures 2.2:1, below even the 3:1 large-text floor — and is-good is the
   DEFAULT state of both summaries, so the most-seen tiles were the worst. The
   ground tint and the --fault-edge border carry the severity; --text on the
   tint measures 9.0-11.0:1 across all three states in both themes. */
.fault-stat-num {
  font-size: 22px;
  font-weight: 600;
  line-height: 1.15;
  letter-spacing: -0.01em;
  font-variant-numeric: tabular-nums;
  color: var(--text);
}
.fault-stat-sub { font-size: 11.5px; color: var(--muted); margin-top: 4px; }

.fault-section { margin-bottom: 26px; }
.fault-section-title { font-size: 15px; font-weight: 600; color: var(--text); margin: 0 0 2px; }
.fault-section-sub { font-size: 12px; margin: 0 0 10px; }

.fault-table { width: 100%; border-collapse: collapse; table-layout: fixed; }
.fault-table th, .fault-table td {
  text-align: left;
  padding: 8px 10px;
  border-bottom: 1px solid var(--line);
  vertical-align: top;
}
.fault-table th {
  font-size: 11.5px;
  text-transform: uppercase;
  letter-spacing: 0.03em;
  color: var(--muted);
  font-weight: 600;
}
.fault-table tbody tr { cursor: pointer; }
.fault-table tbody tr:hover { background: var(--primary-soft); }
.fault-table .col-fleet { width: 130px; }
.fault-table .col-reg { width: 96px; }
.fault-table .col-vin { width: 190px; }
.fault-table .col-soh { width: 116px; }
.fault-table .col-vin code { color: var(--text); }
/* table-layout is fixed, so the identity columns must clip + ellipsize
   rather than overflow their box (matching #telematics-table). The
   faults / codes column has no width and is free to wrap its chips. */
.fault-table td.col-fleet,
.fault-table td.col-reg,
.fault-table td.col-vin,
.fault-table td.col-soh {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* ── mileage report ──
   Ten columns in pixel widths pushed Lifetime past the visible edge of .table-wrap on a
   laptop. Percentage widths instead — the same fix #report-table uses for the same reason:
   every column stays in view, long values (operator, type, geofence) ellipsize, and the
   full text stays available via the title attribute. */
/* Keyed on the CLASS each column carries, not its position: nth-child widths and the
   `.col-*` classes in the markup would drift apart the moment a column is inserted or
   reordered, with no selector looking wrong and the pixel `.col-fleet`/`.col-reg` rules
   above silently dead for this table. */
#mileage-table .col-fleet     { width:  9%; }
#mileage-table .col-reg       { width:  7%; }
#mileage-table .col-job       { width:  7%; }
#mileage-table .col-operator  { width: 12%; }
#mileage-table .col-bustype   { width: 13%; }
#mileage-table .col-location  { width: 13%; }
#mileage-table .col-num       { width:  9%; }
#mileage-table .col-reporting { width: 12%; } /* 9+7+7+12+13+13+(9*3)+12 = 100 */
#mileage-table td {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* The reporting reason is the actionable text — "Last reported 12 days ago" clipped to
   "Last reported 12 d…" loses the number. It wraps, and the cell carries a title too. */
#mileage-table td.col-reporting {
  white-space: normal;
  overflow: visible;
  text-overflow: clip;
}
/* Mileage is read DOWN a column and compared, which proportional digits make impossible. */
.fault-table th.col-num,
.fault-table td.col-num {
  text-align: right;
  font-variant-numeric: tabular-nums;
}
/* The mileage rows are not clickable — there is no per-bus mileage page to open —
   so they must not offer the pointer the other fault tables do. */
#mileage-table tbody tr { cursor: default; }

/* Pushes the buttons to the right of the controls row without giving the status line a
   stretched box. The day/range/reporting selects are plain `.report-control` children —
   same markup and theming as every other report's filters, rather than a bespoke pair. */
.row-header-spacer { flex: 1 1 auto; }

.fault-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.fault-chip {
  display: inline-block;
  font-size: 11.5px;
  padding: 2px 8px;
  border-radius: var(--radius-pill);
  border: 1px solid transparent;
  max-width: 300px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.fault-chip-named {
  background: color-mix(in srgb, var(--error) 16%, transparent);
  color: var(--error);
  border-color: color-mix(in srgb, var(--error) 38%, transparent);
}
.fault-chip-warning {
  background: color-mix(in srgb, var(--warn) 16%, transparent);
  color: var(--warn);
  border-color: color-mix(in srgb, var(--warn) 38%, transparent);
}
.fault-chip-code {
  background: color-mix(in srgb, var(--muted) 14%, transparent);
  color: var(--muted);
  border-color: color-mix(in srgb, var(--muted) 26%, transparent);
}
.fault-chip-more { color: var(--muted); font-size: 11.5px; align-self: center; }

.soh-val { font-weight: 600; }
.soh-val.is-low { color: var(--warn); }

.fault-codes { margin-bottom: 26px; }
.fault-codes > summary { cursor: pointer; }
.fault-codes[open] > summary { margin-bottom: 8px; }

.fault-empty { color: var(--muted); font-size: 13px; cursor: default; }
.fault-table tbody tr:has(.fault-empty) { cursor: default; }
.fault-table tbody tr:has(.fault-empty):hover { background: none; }


/* ──────────── Theme manager (/theme) ────────────
   Two columns: the slot editor on the left, the two live previews on the right,
   collapsing to one column under 1000px. Everything is drawn with the existing
   tokens and the existing .report-* / .btn-* patterns, so this page inherits
   whatever theme the viewer is running — which matters more here than anywhere
   else, because the thing being edited is a DIFFERENT palette and the two must
   not be confusable. That is also why the previews are boxed and labelled. */
.theme-editor {
  display: grid;
  grid-template-columns: minmax(320px, 420px) 1fr;
  gap: 20px;
  align-items: start;
  margin-bottom: 18px;
}
@media (max-width: 1000px) {
  .theme-editor { grid-template-columns: 1fr; }
}

.theme-slots-wrap {
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--panel);
  padding: 12px 14px;
}
/* The label column plus one colour input per variant — the same three-track
   grid on the head row and every slot row, so the two columns line up without
   either knowing the other's width. */
.theme-slots-head,
.theme-slot {
  display: grid;
  grid-template-columns: 1fr 52px 52px;
  gap: 10px;
  align-items: center;
}
.theme-slots-head {
  font-size: 11.5px;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--muted);
  padding-bottom: 8px;
  border-bottom: 1px solid var(--line);
  margin-bottom: 6px;
}
.theme-slots-head span + span { text-align: center; }
.theme-slot { padding: 5px 0; }
.theme-slot-label {
  font-size: 13px;
  display: flex;
  flex-direction: column;
  gap: 1px;
}
.theme-slot-label code {
  font-family: var(--font-mono);
  font-size: 11px;
  color: var(--muted);
}
/* A native colour swatch, trimmed to match the rest of the controls: browsers
   give the input its own chrome, so the padding/border reset is what stops it
   rendering as a grey button with a stamp in the middle. */
.theme-slot input[type="color"] {
  -webkit-appearance: none;
  appearance: none;
  width: 100%;
  height: 30px;
  padding: 0;
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-sm);
  background: none;
  cursor: pointer;
}
.theme-slot input[type="color"]::-webkit-color-swatch-wrapper { padding: 2px; }
.theme-slot input[type="color"]::-webkit-color-swatch {
  border: none;
  border-radius: calc(var(--radius-sm) * 0.7);
}
.theme-slot input[type="color"]::-moz-color-swatch {
  border: none;
  border-radius: calc(var(--radius-sm) * 0.7);
}

.theme-previews {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
  gap: 16px;
}
/* THE TOKENS ARE SET ON THIS ELEMENT (inline, by renderThemeEditor) and every
   descendant reads them through var() — which is the whole preview mechanism.
   So the panel must paint its own background and text: inheriting them would
   show the app's colours instead of the edited ones. */
.theme-preview {
  border: 1px solid var(--line-strong);
  border-radius: var(--radius);
  overflow: hidden;
  background: var(--bg);
  color: var(--text);
  font-family: var(--font-body);
}
.theme-preview-head {
  padding: 6px 10px;
  font-size: 11.5px;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  background: var(--bg-deep);
  color: var(--muted);
  border-bottom: 1px solid var(--line);
}
.theme-preview-body { display: flex; gap: 0; min-height: 220px; }
.theme-preview-rail {
  width: 96px;
  flex: 0 0 auto;
  padding: 10px 8px;
  background: var(--bg-deep);
  border-right: 1px solid var(--line);
  display: flex;
  flex-direction: column;
  gap: 4px;
}
.theme-preview-rail-item {
  font-size: 11.5px;
  padding: 5px 7px;
  border-radius: var(--radius-sm);
  color: var(--muted);
}
.theme-preview-rail-item.is-active {
  background: var(--primary-soft);
  color: var(--text);
}
.theme-preview-main {
  flex: 1 1 auto;
  padding: 12px;
  display: flex;
  flex-direction: column;
  gap: 10px;
  min-width: 0;
}
.theme-preview-card {
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--panel);
  padding: 10px 12px;
}
.theme-preview-card-title {
  font-size: 11.5px;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--muted);
}
.theme-preview-card-val { font-size: 22px; font-weight: 600; }
.theme-preview-muted { color: var(--muted); font-size: 12px; }
.theme-preview-btns { display: flex; gap: 8px; }
.theme-preview-btn-primary,
.theme-preview-btn-secondary {
  font-size: 12px;
  padding: 6px 12px;
  border-radius: var(--radius-sm);
}
.theme-preview-btn-primary { background: var(--primary); color: var(--primary-ink); }
.theme-preview-btn-secondary {
  background: var(--panel-2);
  color: var(--text);
  border: 1px solid var(--line-strong);
}
.theme-preview-badges { display: flex; flex-wrap: wrap; gap: 6px; }
.theme-preview-badge {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  font-size: 11.5px;
  padding: 3px 8px;
  border-radius: var(--radius-pill);
  border: 1px solid color-mix(in srgb, var(--badge) 40%, transparent);
  background: color-mix(in srgb, var(--badge) 12%, transparent);
}
.theme-preview-dot {
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--badge);
}
.theme-preview-table {
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  background: var(--panel);
  overflow: hidden;
}
.theme-preview-row {
  display: flex;
  justify-content: space-between;
  gap: 10px;
  padding: 6px 10px;
  font-size: 12px;
}
.theme-preview-row + .theme-preview-row { border-top: 1px solid var(--line); }
.theme-preview-row span + span { color: var(--muted); }

/* The contrast floors. `[hidden]` guard REQUIRED — an author `display` outranks
   the user agent's `[hidden] { display: none }`, and this element is toggled
   with el.hidden, so without the guard a passing theme would still show an
   empty red box (the trap route-classification.test.mjs pins). */
.theme-warnings {
  display: block;
  margin: 0 0 16px;
  padding: 10px 12px;
  border-radius: var(--radius-sm);
  border: 1px solid color-mix(in srgb, var(--error) 40%, transparent);
  background: color-mix(in srgb, var(--error) 10%, transparent);
  font-size: 13px;
}
.theme-warnings[hidden] { display: none; }
.theme-warnings ul { margin: 6px 0 0; padding-left: 18px; }
.theme-warnings li { margin-bottom: 2px; }

.theme-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.theme-actions[hidden] { display: none; }
#theme-view .users-error { margin: 0 0 14px; }

/* Fleet logo. A panel, not a control row: the preview needs a surface behind it
   or a transparent PNG has nothing to sit on and reads as clipped. */
.theme-logo {
  margin: 22px 0 0;
  padding: 14px 16px;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--panel);
}
.theme-logo-title { margin: 0; font-size: 14px; font-weight: 600; }
.theme-logo-sub { margin: 4px 0 12px; font-size: 12.5px; color: var(--muted); }
.theme-logo-row { display: flex; align-items: flex-start; gap: 18px; flex-wrap: wrap; }
/* Deliberately taller than the 28px the header renders at: the artwork is
   uploaded at 2× or more, and an admin checking whether the right file landed
   should be able to see it. --bg-deep rather than --panel so a white-on-
   transparent mark is visible in light mode too. */
.theme-logo-preview {
  display: flex;
  align-items: center;
  justify-content: center;
  min-width: 200px;
  height: 64px;
  padding: 0 14px;
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  background: var(--bg-deep);
}
.theme-logo-preview img { max-height: 40px; max-width: 260px; width: auto; object-fit: contain; }
.theme-logo-preview img[hidden] { display: none; }
.theme-logo-controls { display: flex; flex-direction: column; gap: 8px; min-width: 260px; }
.theme-logo-label { font-size: 12px; color: var(--muted); max-width: 420px; }
.theme-logo-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }

/* ──────────── "HeyHy!" AI assistant — the launcher ────────────
   A persistent circular button in the bottom-right corner. The panel that grows
   out of it is the shared widget's own, styled further down this file; this app no
   longer draws a chat window of its own.

   One set of geometry tokens drives the launcher, its hit pad AND the widget
   panel's offset and height cap (see --heyhy-panel-* below), so they cannot drift
   apart: change the size here and the rest follows. Nothing else may share this
   corner — see APP_DESIGN.md 12.5. */
:root {
  --ai-launcher-size: 56px;
  --ai-launcher-inset: 20px;
  --ai-launcher-gap: 12px;
  /* Invisible hit slop outside the visible circle. Its job is to keep the
     hit-test boundary AWAY from where a hand actually moves: with the boundary
     sitting exactly on the visible edge, sweeping across the icon straddles it
     and the cursor flips hand/arrow repeatedly. 14px is enough that crossing the
     circle you can see never crosses the boundary. */
  --ai-launcher-slop: 14px;
}

/* Mid-session re-auth prompt (see #session-expired-overlay in index.html). Sits above
   every panel so an expired session can't be worked around by clicking underneath. */
.session-expired-overlay {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.55);
  z-index: 2000;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
  animation: fade-in 140ms ease-out;
}
.session-expired-overlay[hidden] { display: none; }
.session-expired-card {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  box-shadow: var(--shadow-modal);
  padding: 28px 32px;
  max-width: 380px;
  text-align: center;
}
.session-expired-card h2 { margin: 0 0 8px; }
.session-expired-card p { margin: 0 0 20px; color: var(--muted); }
.session-expired-card button {
  background: var(--primary);
  color: var(--primary-ink);
  border: 0;
  border-radius: var(--radius-sm);
  padding: 11px 18px;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
  transition: filter 120ms ease, transform 120ms ease;
}
.session-expired-card button:hover { filter: brightness(1.08); }
.session-expired-card button:active { transform: translateY(1px); }

/* The launcher. z-index sits BELOW .side-menu-overlay (1700) so an open side
   menu dims it along with the page, and far below #session-expired-overlay
   (2000), which must stay unbeatable. That also puts it below #map.is-expanded
   (3000), so the launcher is hidden while the map is expanded — matching what
   native :fullscreen does to it anyway, rather than having the two disagree. */
.ai-launcher {
  position: fixed;
  right: var(--ai-launcher-inset);
  bottom: var(--ai-launcher-inset);
  height: var(--ai-launcher-size);
  width: var(--ai-launcher-size);
  z-index: 1650;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  border-radius: 50%;
  border: 1px solid var(--line-strong);
  background: var(--panel);
  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.28);
  cursor: pointer;
  /* A drag that starts on the launcher must not select it or ghost-drag it —
     both replace the pointer cursor part-way through the gesture. */
  user-select: none;
  -webkit-user-select: none;
  /* No `transform` in this list, on purpose — see the invariant below. */
  transition: box-shadow 140ms ease, border-color 120ms ease, background 120ms ease;
}
.ai-launcher[hidden] { display: none; }
/* `border-radius: 50%` clips HIT-TESTING as well as painting, so the button's
   clickable area is a circle inside a square footprint. The four corner slivers
   then belong to whatever is underneath — the map (`cursor: grab`, an open hand)
   or the dashboard (`auto`, an arrow) — and moving the pointer across the bubble
   anywhere but dead-centre clips in and out of them, changing the cursor
   repeatedly.
   This square pad restores a rectangular hit area behind the round visual, so
   the cursor is `pointer` across the whole footprint and changes once, at the
   edge of the box, instead of flickering at the curve. It paints nothing;
   hitting a pseudo-element counts as hitting its originating element, so hover,
   click and focus all still belong to the button. `-1px` so it covers the
   border too. The trade-off, accepted: the ~8px corners now activate the chat
   though they look like map, so the pad is kept to the footprint and not
   enlarged beyond it. */
.ai-launcher::before {
  content: '';
  position: absolute;
  /* Hit slop on the two sides you approach from. A sub-pixel scan at DPR 2
     confirmed the edges themselves are clean — one transition, no alternating
     band — so the flicker was simply the boundary living on the visible edge,
     where hand movement crosses it back and forth. Moving it 14px out means
     sweeping across the visible circle stays wholly inside one cursor. */
  inset: calc(-1 * var(--ai-launcher-slop));
  /* Reach the viewport's right and bottom edges. The launcher is inset 20px from
     the corner, which leaves two thin strips of page between it and the window
     edge; those strips are `cursor: auto` (an arrow), so moving across the bubble
     towards the corner flips the cursor, and a boundary that lands on a
     half-pixel at DPR 2 flips it repeatedly under normal hand jitter. A trace
     from a real session showed exactly that: 477 consecutive samples of
     `pointer` over the bubble, and the only other state was `dashboard-view`
     [auto] in the 20px strip to its right.
     Left and top stay tight, because extending there would take clicks away
     from the map for no cursor benefit. */
  right: calc(-1 * var(--ai-launcher-inset));
  bottom: calc(-1 * var(--ai-launcher-inset));
}
/* INVARIANT: the launcher's GEOMETRY NEVER CHANGES on interaction. Hover and
   press are carried entirely by colour and shadow — properties that cannot
   affect hit-testing.

   This is deliberately stricter than it needs to be, and it is the third pass at
   it. Hover first lifted the button (`translateY(-2px)`), which pulled it out
   from under a pointer in the bottom 2px: hover ended, it dropped back, hover
   began again, and the cursor oscillated between the pointer hand and whatever
   was underneath — Leaflet's `grab` over the map, an arrow over the dashboard.
   Replacing the lift with `scale(1.06)` fixed that specific loop (a point inside
   the resting box is still inside a scaled-UP box), and measured clean under a
   real pointer. But any interactive geometry change is a standing invitation to
   the same class of bug at some zoom level, pixel ratio or transition timing, and
   a 6% grow on a 56px circle is not worth that risk. So: no transform at all.

   If you want motion here, animate something that does not participate in hit
   testing — box-shadow, border-color, or the mark inside the button. */
.ai-launcher:hover {
  box-shadow: 0 10px 26px rgba(0, 0, 0, 0.34);
  border-color: var(--primary);
  background: color-mix(in srgb, var(--panel) 92%, var(--primary));
}
.ai-launcher:active {
  box-shadow: 0 3px 10px rgba(0, 0, 0, 0.26);
  border-color: var(--primary);
}
.ai-launcher:focus-visible { outline: 2px solid var(--primary); outline-offset: 3px; }
/* Open: the launcher stays exactly where it is — it is the same control that
   closes the window, and moving or swapping it would break that reading. It
   just reads as pressed, and the mark tucks in slightly. */
.ai-launcher[aria-expanded="true"] {
  border-color: var(--primary);
  box-shadow: 0 4px 14px rgba(0, 0, 0, 0.26);
}
.ai-launcher[aria-expanded="true"] .ai-launcher-mark { transform: scale(0.88); }
.ai-launcher-mark {
  height: 30px;
  width: 30px;
  display: block;
  /* The button is the only hit target. Without this the <img> is what the
     pointer lands on, so dragging across the launcher starts a native image
     drag and swaps the cursor for a drag/no-drop one mid-gesture. */
  pointer-events: none;
  transition: transform 160ms ease;
}
/* Same dark-first swap as .brand-logo-on-*: see the note in index.html. */
.ai-mark-on-light { display: none; }
[data-theme="light"] .ai-mark-on-dark { display: none; }
[data-theme="light"] .ai-mark-on-light { display: block; }

/* Motion is decoration here; the launcher reads as pressed either way. Honour
   the preference and just let the state change without an animation. */
@media (prefers-reduced-motion: reduce) {
  .ai-launcher,
  .ai-launcher-mark { transition: none; }
}

/* "HeyHy! could not be loaded" — the note app.js draws (heyhyMountError) when the
   vendored bundle fails to fetch. There is no panel to put a message inside any
   more, so it sits in the launcher's own corner, just above it, using the same
   geometry tokens so it can never overlap the button it is about. Above the
   launcher's z-index and below the side-menu overlay, like the launcher itself. */
.ai-launch-error {
  position: fixed;
  right: var(--ai-launcher-inset);
  bottom: calc(var(--ai-launcher-inset) + var(--ai-launcher-size) + var(--ai-launcher-gap));
  z-index: 1660;
  max-width: min(300px, calc(100vw - 2 * var(--ai-launcher-inset)));
  padding: 10px 12px;
  border: 1px solid var(--error);
  border-radius: var(--radius-sm);
  background: var(--panel);
  box-shadow: 0 10px 28px rgba(0, 0, 0, 0.32);
  color: var(--text);
  font-size: 12.5px;
  line-height: 1.45;
}
.ai-launch-error[hidden] { display: none; }
/* "est." marker on a dwell whose exit was inferred rather than observed (the bus
   stopped reporting outside the geofence). Same pill geometry as .warranty-badge
   so it reads as part of the same system; muted, because it qualifies the number
   beside it rather than competing with it. */
.ge-est {
  display: inline-block;
  padding: 1px 6px;
  border-radius: var(--radius-pill);
  background: rgba(88,116,129,0.18);
  color: var(--muted);
  font-size: 10.5px;
  font-weight: 600;
  white-space: nowrap;
  cursor: help;
}

/* ════════════════ VOR (Vehicles off road) ════════════════
   Off-road state is a warning condition, so it reads in --warn (orange)
   throughout — distinct from the green live/healthy language used everywhere
   else, and from --error, which the app reserves for failed requests. */

/* --- VOR topcard (#card-vor) ---
   Holds the off-road counts (which ARE the map filter — each number is a button)
   and the link to the report. The controls row sits under the numbers with a rule
   above it so the filter hint and the report link read as a footer rather than as
   more subline text. */

/* Two stats side by side: reported, and not moved 24h. Equal columns, each number
   centred in its own column, so neither reads as the primary one and the pair
   stays balanced in the 320px dashboard rail. The rest of the card stays
   left-aligned — only this block centres.

   EQUAL-SIZED BOXES, because these are buttons now and a pair of filter targets at
   different heights reads as a mistake. Three things hold it, and they are
   deliberately independent — a wording change must not be able to unsquare the
   card:
     - `align-items: stretch` (the grid default, so left implicit) makes the boxes
       the same height whatever the labels do. It replaced `align-items: start`,
       which kept the numbers on one line by letting the boxes differ instead.
     - the button is a flex COLUMN with `justify-content: flex-start`, which is what
       keeps the numbers on one line under stretch. A `display: block` button whose
       box is taller than its content has that content VERTICALLY CENTRED by the UA,
       so the shorter label's number sank ~8px below the other's — measured, after
       assuming the opposite.
     - the labels are short enough not to wrap in the first place, so the boxes are
       equal AND compact. "not moved in last 24 hours" wrapped to two lines at every
       card width below 380px, making that box 78px against the other's 61. */
.card-vor-stats {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 10px;
  text-align: center;
}
/* Each stat is a button that filters the map. The "on" ramp is --warn, not
   --primary, because off-road is a warning condition — the same language the
   switch this replaced used, and the same one the VOR pins and the report use.
   Transparent border in the resting state so pressing one does not shift the
   layout by a pixel. */
.card-vor-stat {
  /* Flex column, not block: see the third point above — a block button centres its
     content vertically when the grid stretches it taller than that content, which
     drops one number below the other. */
  display: flex;
  flex-direction: column;
  justify-content: flex-start;
  width: 100%;
  padding: 6px 4px;
  background: none;
  border: 1px solid transparent;
  border-radius: var(--radius-sm);
  color: inherit;
  font: inherit;
  text-align: center;
  cursor: pointer;
  transition: background 120ms ease, border-color 120ms ease;
}
/* .big / .small are written for block context, and inside the button they are
   spans (a button takes phrasing content only — see the markup comment). Being
   flex items already blockifies them, so this is belt-and-braces: it keeps them
   correct if the button ever stops being a flex container. */
.card-vor-stat > .big,
.card-vor-stat > .small { display: block; }
.card-vor-stat:hover:not(:disabled) { border-color: var(--warn); }
.card-vor-stat[aria-pressed="true"] {
  border-color: var(--warn);
  background: color-mix(in srgb, var(--warn) 14%, transparent);
}
.card-vor-stat[aria-pressed="true"] > .big { color: var(--warn); }
.card-vor-stat[aria-pressed="true"] > .small { font-weight: 600; }
/* Disabled = this filter would empty the map. renderVorCard sets it when the set
   is empty, and only while the filter is OFF — never strand an active filter
   with no way back. */
.card-vor-stat:disabled { cursor: not-allowed; opacity: 0.55; }
.card-vor-stat:focus-visible { outline: 2px solid var(--warn); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) {
  .card-vor-stat { transition: none; }
}
#card-vor-scope { text-align: center; }
.card-vor-controls {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  /* NOWRAP, deliberately, and it is load-bearing. The hint's text changes with the
     active filter, and `wrap` made the row's HEIGHT depend on which message was
     showing: at the 320px rail width the longer active text pushed the hint onto
     its own line and the whole card grew 170px → 197px on every click, shifting
     every card below it. Measured, then fixed. The messages are all short enough
     to share the row (172px worst case against ~188px available), but the strings
     must not be what guarantees it — a wider font or a zoomed browser would bring
     the jump straight back. So the row cannot wrap and the hint truncates instead;
     renderVorCard puts the full text in its title. */
  flex-wrap: nowrap;
  margin-top: 12px;
  padding-top: 10px;
  border-top: 1px solid var(--line);
}
/* The hint is what gives way if anything has to: it can be re-read from the
   pressed button and its title, whereas a truncated "View VOR repor…" is just
   broken. */
#card-vor-filter-hint {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.card-vor-link {
  flex: 0 0 auto;
  color: var(--primary);
  font-size: 11.5px;
  text-decoration: none;
  white-space: nowrap;
}
.card-vor-link:hover { text-decoration: underline; }
.card-vor-link:focus-visible {
  outline: 2px solid var(--primary);
  outline-offset: 2px;
  border-radius: 3px;
}
/* The as-at line sits below the controls rule, so it needs more air than the
   4px .card .small gives a subline sitting straight under its number. */
#card-vor-asof { margin-top: 8px; }

/* VOR line inside a map marker tooltip. */
.tooltip-vor { color: var(--warn); font-weight: 600; }
/* Status line inside a pin tooltip: the pin's own dot, its freshness bucket in
   words, and the age of the fix. inline-flex, not flex: the tooltip's lines are
   separated by <br>, and a block-level box between two of them would open a
   blank line above itself. */
.pin-tip-status {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  margin-top: 3px;
  font-size: 11px;
  opacity: 0.85;
}
/* Last line of a pin tooltip (drivetrain · click for details) — was an inline
   style before, which the CSP would have to allow. */
.pin-tip-muted { opacity: 0.65; }
/* Same fills as the map pins, off the same tokens (see --pin-* in :root), so a
   tooltip dot and the pin it describes can't disagree. */
.sdot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; flex: none; }
.s-live { background: var(--pin-live, var(--good)); }
.s-online { background: var(--pin-online, var(--warn)); }
.s-offline { background: var(--pin-offline); }
.s-unknown { background: var(--pin-unknown); }

/* --- sidebar banner (#bus-detail) --- */
.bd-vor {
  border: 1px solid color-mix(in srgb, var(--warn) 45%, transparent);
  background: color-mix(in srgb, var(--warn) 12%, transparent);
  border-radius: var(--radius-sm);
  padding: 10px 12px;
  margin-bottom: 12px;
}
.bd-vor[hidden] { display: none; }
.bd-vor-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.bd-vor-badge {
  background: var(--warn);
  color: var(--hb-forest-ink);
  font-size: 10px;
  font-weight: 700;
  letter-spacing: 0.04em;
  padding: 2px 6px;
  border-radius: var(--radius-pill);
  flex: 0 0 auto;
}
.bd-vor-summary { font-size: 13px; font-weight: 600; color: var(--warn); }
.bd-vor-list { display: flex; flex-direction: column; gap: 8px; }
.bd-vor-fault + .bd-vor-fault { border-top: 1px solid var(--line); padding-top: 8px; }
.bd-vor-reason { font-size: 13px; font-weight: 600; }
/* Free-text fault wording from the export — wrap rather than truncate; an
   operator needs the whole sentence to act on it. */
.bd-vor-text { font-size: 12px; color: var(--muted); overflow-wrap: anywhere; }
.bd-vor-meta { font-size: 11px; margin-top: 2px; }
/* Marks a reason auto-classified from shorthand not yet confirmed. */
.bd-vor-flag {
  display: inline-block;
  font-size: 10px;
  font-weight: 700;
  color: var(--warn);
  border: 1px solid currentColor;
  border-radius: var(--radius-pill);
  width: 13px;
  height: 13px;
  line-height: 11px;
  text-align: center;
  cursor: help;
}
.bd-vor-asof { font-size: 11px; margin-top: 8px; }

/* --- vehicle page card --- */
/* Full row: each fault carries free-text fault + next-step notes that need the
   width to stay legible. Sizes to its content (one or two faults reads as a
   compact strip with the "VOR as at" line directly under the rows). Bounded at
   1.5 standard cards so a genuinely long fault list stays on screen without
   pushing the rest of the page away — .vh-vor-faults scrolls beyond that. */
.vh-card-vor {
  grid-column: 1 / -1;
  height: auto;
  max-height: calc(var(--vh-card-h) * 1.5);
}
/* Faults tile across the card, so a multi-fault bus uses the width it has been
   given instead of running off the bottom. The track is bounded at both ends:
   560px is the narrowest a fault reads well at (label column + a usable measure
   for the free text) and 760px the widest before the free-text lines get too long
   to scan — so a wide screen gets two tiles rather than three stretched ones, and
   a narrow one gets a single column. */
.vh-vor-faults {
  flex: 1 1 auto;
  min-height: 0;
  overflow: auto;
  scrollbar-gutter: stable;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(min(100%, 560px), 760px));
  justify-content: start;
  align-content: start;
  gap: 12px;
}
/* Each fault is its own panel. Tiles can't use a between-items border-top the way
   a single stacked column did — with two columns it would read as a stray rule —
   so the separation is the panel itself. */
.vh-vor-fault {
  background: color-mix(in srgb, var(--warn) 7%, var(--panel));
  border: 1px solid color-mix(in srgb, var(--warn) 28%, transparent);
  border-radius: var(--radius-sm);
  padding: 12px 14px;
}
.vh-vor-fault-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.vh-vor-reason { font-size: 13px; font-weight: 600; }
/* Label column + value left-aligned beside it. The label width is fixed (not
   max-content) so every fault tile aligns on the same value edge even when one
   of them is missing the longest label. */
.vh-vor-rows {
  display: grid;
  grid-template-columns: 116px minmax(0, 1fr);
  column-gap: 16px;
  row-gap: 6px;
  margin: 0;
  font-size: 13px;
}
.vh-vor-k { color: var(--muted); }
.vh-vor-v { margin: 0; overflow-wrap: anywhere; }
/* Phone width: one tile is ~265px, so a fixed 116px label column would leave the
   free-text Fault/Next step values ~100px to wrap in. Stack label over value
   instead — the alignment the fixed column buys is worthless at one tile wide. */
@media (max-width: 620px) {
  /* row-gap stays tight so a label sits with its own value; the space between
     pairs comes from the label's margin instead. */
  .vh-vor-rows { grid-template-columns: minmax(0, 1fr); row-gap: 2px; }
  .vh-vor-k { margin-top: 8px; }
  .vh-vor-k:first-child { margin-top: 0; }
}

/* --- reports view --- */
/* Provenance line. Neutral while the snapshot is current; orange once
   vorStaleNote() decides it is old enough to warn about. */
/* Drag-and-drop target for the snapshot import. Fixed rather than absolute so a
   long report (54 faults scrolls well past a viewport) shows the hint wherever
   the reader happens to be, instead of only at the top of the page. */
.vor-drop-hint {
  position: fixed;
  inset: 0;
  z-index: 60;
  display: flex;
  align-items: center;
  justify-content: center;
  /* Same wash as .app-modal-backdrop — one overlay treatment in the app, and it
     works in both themes without a colour-mix dependency. */
  background: rgba(0, 0, 0, 0.42);
  pointer-events: none; /* never swallow the drop itself */
}
.vor-drop-hint[hidden] { display: none; }
.vor-drop-hint > span {
  border: 2px dashed var(--primary);
  border-radius: var(--radius);
  background: var(--panel);
  color: var(--text);
  padding: 22px 32px;
  font-size: 15px;
  font-weight: 600;
  box-shadow: 0 8px 30px rgba(0, 0, 0, 0.35);
}

/* Collapsible "about this report" block under a report title. A real <details>
   /<summary>, so the disclosure triangle, the keyboard behaviour and the
   screen-reader announcement are all the browser's — nothing to reimplement and
   nothing to get wrong. Closed on load: the body is reference material, and the
   one fact that must always be legible (the snapshot's as-at date) is on the
   summary line itself rather than inside. */
.report-about { margin-top: 6px; }
.report-about > summary {
  cursor: pointer;
  color: var(--muted);
  font-size: 12px;
  /* Shrink-wrap the click target. Left full-width, the row is the width of the
     page and a click anywhere on that line toggles the section — including in the
     empty space beside the text, which reads as a misfire. */
  width: fit-content;
}
.report-about > summary:hover { color: var(--text); }
.report-about > summary:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
.report-about[open] > summary { margin-bottom: 8px; }
/* Spacing only — the paragraphs keep .report-sub's own colour and size so this
   block reads as the same small print it replaced. */
.report-about-body > p { margin: 0 0 6px; }
.report-about-body > p:last-child { margin-bottom: 0; }
.report-about-body > p[hidden] { display: none; }
/* Same emphasis rule as .vor-provenance below: weight, never colour. */
.report-about-line.is-stale { font-weight: 600; }

.vor-provenance { font-size: 12px; }
/* Provenance, not an alert: the as-at line keeps the muted colour of the small
   print around it even when the snapshot is stale, because the words already
   say so ("VOR snapshot is 4 days old") and a whole line of --warn on every
   load reads as a fault in the app. The 600 weight is the only emphasis a stale
   snapshot gets. --warn stays reserved for live-feed trouble (#map.is-stale)
   and the pressed state of the card's map-filter buttons. */
.vor-provenance.is-stale { font-weight: 600; }
.vor-summary {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  margin: 12px 0;
}
.vor-summary[hidden] { display: none; }
.vor-tile {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  padding: 8px 14px;
  min-width: 86px;
}
.vor-tile-val { font-size: 18px; font-weight: 700; line-height: 1.15; }
.vor-tile-label { font-size: 11px; color: var(--muted); margin-top: 2px; }
/* Report rows are clickable through to the full vehicle page where we resolved
   a VIN; unmatched rows deliberately are not. */
#vor-table tr.is-clickable { cursor: pointer; }
#vor-table tr.is-clickable:hover { background: var(--primary-soft); }

/* VOR report table: must FIT the page rather than scroll sideways.
   `table { min-width: 720px }` plus the .warranty-table nowrap rule means an
   auto-layout table with this many columns grows past the 1280px page and
   pushes the first column out of view. Fixed layout + the colgroup widths
   renderVorReport() emits makes each column obey its share, so the existing
   ellipsis actually engages; every truncated cell carries a title tooltip. */
.vor-table {
  table-layout: fixed;
  min-width: 0;
  width: 100%;
}
.vor-table th,
.vor-table td {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* Slightly tighter padding + type: this table is information-dense and the
   saved horizontal space goes to the free-text fault wording. */
.vor-table th,
.vor-table td {
  padding-left: 8px;
  padding-right: 8px;
  font-size: 12.5px;
}
.vor-table td[title] { cursor: help; }
.vor-table tr.is-clickable td[title] { cursor: pointer; }
/* Sortable headers on a 12-column table. The shared table.sortable rule reserves
   22px on the right for the ▲/▼ indicator, which on columns this narrow is enough
   to ellipsise six of the twelve LABELS — and a clipped header is the one kind
   this table can't afford, because it stops the reader knowing what the column is.
   So: a tighter indicator gutter, and half the usual header letter-spacing, which
   between them buy back roughly 12px per header. Measured, not estimated — see
   the width note on VOR_FAULT_COLUMNS in app.js. */
.vor-table.sortable th[data-sort-key] { padding-right: 14px; }
/* Only the OFFSET moves in — the indicator is absolutely positioned, so its glyph
   size costs the label nothing and stays the same as every other sortable table. */
.vor-table.sortable th .sort-ind { right: 3px; }
.vor-table th { letter-spacing: 0.06em; }
/* HEADERS WRAP, CELLS DO NOT. Everywhere else in the app a table header wraps
   (it is the browser default this table opted out of); .vor-table turned that off
   so headers behaved like its nowrap cells. With fourteen columns and labels as
   long as "Miles since raised (OBD)" — 210px on one line, 17% of the table to
   itself — that is no longer affordable, and clipping the label is the one thing
   this header row must not do. Wrapping costs a second header line and gives the
   column back to the data. Cells keep nowrap + ellipsis: a wrapping CELL would
   set the height of its whole row from one long fault description. */
.vor-table th { white-space: normal; vertical-align: bottom; }
.vor-table.sortable th[data-sort-key] .th-label {
  white-space: normal;
  overflow: visible;
  text-overflow: clip;
}

/* ──────────── "HeyHy!" — the shared widget ────────────
   @hydrab/heyhy-widget, vendored at frontend/vendor/heyhy-widget.esm.js and
   mounted by frontend/ui/heyhy-mount.js on the first open. None of this matches
   anything until then: the widget's DOM does not exist before it is mounted.

   The widget ships no palette. Every colour, radius and font in it is read as
   var(--heyhy-…, neutral-fallback), so mapping each token to one of this app's own
   is what makes it look like this app — and because the mapping is a variable
   indirection resolved at use time, it follows html[data-theme] with no second
   theme mechanism and no duplicated palette.

   This block MUST stay in step with THEME_MAP in frontend/ui/heyhy-widget-config.js:
   api/_shared/_tests/heyhy-widget-config.test.mjs diffs the two token-for-token,
   and separately asserts they cover every token the widget actually reads. An
   unmapped token is not a crash — it silently falls back to the package's neutral
   placeholder, legible and visibly not this app, in exactly one of the two themes.

   Scoped to .heyhy-root rather than :root: these names mean nothing outside the
   widget, and declaring 19 of them on the document root is 19 chances to collide
   with something later.

   The last two are the geometry the app's own panel used, expression for
   expression, rather than the package's 400px/640px placeholders. That panel is
   gone; the numbers stay because they are the measured fit for this corner, and
   both are written in terms of the launcher variables so the docked window clears
   #ai-launcher by the same gap the launcher's own tokens define.

   NO COMMENTS INSIDE THE BRACES. The parity test parses this block by splitting
   it on `;`, so a comment sitting in front of a declaration hides that
   declaration from the parser and the token reads as unmapped. */
.heyhy-root {
  --heyhy-font: "Inter", -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  --heyhy-font-mono: ui-monospace, SFMono-Regular, Menlo, monospace;
  --heyhy-font-size: 14px;
  --heyhy-surface: var(--panel);
  --heyhy-surface-raised: var(--panel-2);
  --heyhy-surface-sunken: var(--bg-deep);
  --heyhy-text: var(--text);
  --heyhy-text-muted: var(--muted);
  --heyhy-border: var(--line);
  --heyhy-accent: var(--primary);
  --heyhy-accent-text: var(--primary-ink);
  --heyhy-user-bubble: var(--primary-soft);
  --heyhy-user-bubble-text: var(--text);
  --heyhy-danger: var(--error);
  --heyhy-radius: var(--radius);
  --heyhy-shadow: 0 18px 48px rgba(0, 0, 0, 0.38);
  --heyhy-z: 1800;
  --heyhy-panel-width: min(400px, calc(100vw - 2 * var(--ai-launcher-inset)));
  --heyhy-panel-height: min(600px, calc(100vh - var(--ai-launcher-size) - var(--ai-launcher-inset) * 2 - var(--ai-launcher-gap) - 72px));
}

/* The widget renders its own launcher and has no config flag to suppress it. This
   app already has one — #ai-launcher, with the Hy! mark, the hit slop and the
   scope gating — and two bubbles in the same corner is not a state anyone should
   see (APP_DESIGN.md 12.5: nothing else may share this corner). So the widget's
   launcher is hidden and #ai-launcher drives widget.open()/close() instead.

   display:none, not visibility or opacity: it has to leave the tab order too.
   app.js restores focus to #ai-launcher after close(), because the widget's own
   close() focuses this element and focus on a display:none element goes to body. */
.heyhy-root .heyhy-launcher { display: none; }

/* ── Holding off this app's own bare-element rules ───────────────────────────
   Exactly the problem PR #234 solved for the old panel's `.ai-msg`, arriving
   again because the widget renders markdown into the same stylesheet. (That panel
   is gone now; the trap it fell into is not.) The widget scopes
   every one of its own selectors to `.heyhy-*` — it has no bare element rules at
   all — but scoping is not enough: this file styles the app's data tables, links
   and code spans through BARE ELEMENT selectors (`table`, `th, td`, `th`,
   `tr:last-child td`, `tbody tr:hover td`, `a`, `code`, all near line 1958), and
   those land on widget content too. Two ways they win:

     - Higher specificity outright. `tr:last-child td` is (0,1,2) and
       `tbody tr:hover td` is (0,1,3); the widget's `.heyhy-table-wrap td` is only
       (0,1,1).
     - A declaration on the element beating an INHERITED value, at any
       specificity. `th, td { font-size: 13px }` is (0,0,1) and loses every fight
       it is in, but the widget sets cell size by inheritance from
       `.heyhy-table-wrap table { font-size: 0.92em }` — and an inherited value
       loses to any declaration at all.
     - A property the widget simply does not declare, so there is no fight:
       `a { text-decoration: none }` and `code { color: var(--muted) }` land
       unopposed.

   So each one is restated here, with `.heyhy-root` plus enough of a path to be out
   of reach. The app's own rules are correct for what they were written for — a
   clickable, bottom-ruled grid inside `.table-wrap` — and are not changed to suit
   this panel. Measured, not reasoned about: a headless-Chrome harness rendered the
   same markdown twice, once under this stylesheet and once against the widget's
   own CSS alone, and diffed 33 computed properties across every element. Every
   rule below closes a difference that diff found.

   FOUR DIFFERENCES ARE LEFT OPEN DELIBERATELY, all on `thead th`: the app's
   `th { text-transform: uppercase; letter-spacing: .12em; font-weight: 600;
   color: var(--muted) }` reaches the widget's table headers and the widget would
   otherwise render them as plain bold. That is how every table in this app looks,
   and how the old chat panel's tables looked from PR #234 onwards — matching this
   app is the point of the theme mapping, so these are inherited on purpose, not
   overlooked. */

/* The widget deliberately underlines links (colour alone is not a distinction),
   and this app's `a { text-decoration: none }` removes it. Carried over from the
   old panel's `.ai-msg a`, hover included: it thickens the rule rather than
   adding one. */
.heyhy-root .heyhy-msg a { text-decoration: underline; text-underline-offset: 2px; }
.heyhy-root .heyhy-msg a:hover { text-decoration-thickness: 2px; }

/* `code { color: var(--muted) }` dims inline code to 65% on a sunken background.
   The widget declares the background and leaves the colour to inherit, so nothing
   of its own is being overridden here — the app's rule simply arrives unopposed. */
.heyhy-root .heyhy-msg code { color: var(--text); }
.heyhy-root .heyhy-msg pre code { color: inherit; }

/* `table { min-width: 720px }` from the data-table block. A three-column answer
   was measured at 720px inside a 400px panel — it fits, because the widget's wrap
   scrolls, but every table arrived needing a horizontal scroll it should not need.
   Zeroing it lets the widget's `width: 100%` win. */
.heyhy-root .heyhy-table-wrap table { min-width: 0; }

/* `tr:last-child td { border-bottom: 0 }` suits a `.table-wrap` table, where the
   wrapper's own border closes the grid. A chat table has no such frame, so this
   left the last row of every widget table hanging open — column rules running down
   past the final values with nothing across the bottom. This is the PR #234 bug,
   verbatim, in the new panel. */
.heyhy-root .heyhy-table-wrap table tr:last-child td { border-bottom: 1px solid var(--heyhy-border, var(--line)); }

/* `th, td { font-size: 13px }` pins every cell, beating the 0.92em the widget sets
   on the table. `1em` hands the size back to the table, which is the one place the
   widget wants it set. The header keeps its smaller label size in `em` rather than
   frozen at 10.5px. */
.heyhy-root .heyhy-table-wrap table th,
.heyhy-root .heyhy-table-wrap table td { font-size: 1em; }
.heyhy-root .heyhy-table-wrap table thead th {
  font-size: 0.85em;
  /* Not sticky. `th { position: sticky; top: 0 }` is for the app's tall data
     tables; the widget's wrap scrolls sideways only, so sticky bought nothing
     while making the header a positioned box painting over its neighbours'
     collapsed borders. */
  position: static;
}

/* A chat table row is not clickable, so it must not light up or turn the pointer
   into a hand the way a fleet-list row does. A link inside a cell is clickable and
   keeps the pointer. */
.heyhy-root .heyhy-table-wrap table tbody tr:hover td { background: none; cursor: auto; }
.heyhy-root .heyhy-table-wrap table th a,
.heyhy-root .heyhy-table-wrap table td a {
  color: var(--heyhy-accent, var(--primary));
  text-decoration: underline;
  text-underline-offset: 2px;
  cursor: pointer;
}

/* ── This app's own proportions ───────────────────────────────────────────────
   The theme mapping above gets the widget the right COLOURS. It cannot get the
   right measurements: the package's own numbers (14px base, 12px gutters, 30px
   controls, 10px radii) are a coherent design of their own, and they are not this
   app's.

   Every measurement below is the value this app's own chat panel used. The
   comments still name the `.ai-*` rule each one came from — that panel has been
   deleted, so those names are PROVENANCE, not a live cross-reference, and there is
   nothing left to keep in step with. They are kept because a bare number here
   ("32px", "13.5px") reads as arbitrary without knowing it was the app's.

   Restated here rather than changed in the package for the same reason the
   colours are: these are Uptime 365's proportions, not the widget's, and HydraB
   Telematics mounts the same bundle with its own.

   Measured, not reasoned about: a headless-Chrome harness rendered the panel and
   the mounted widget side by side under this stylesheet, drove both through idle,
   sent, waiting, streaming, finished-with-a-table, recording and speak-back, and
   diffed 36 computed properties across 30 paired elements. Every rule below closes
   a difference that diff found. */

/* .ai-panel: the docked window sits ABOVE the launcher, not over it, and both are
   placed off the launcher variables. The widget hard-codes right/bottom 20px/84px;
   width and height already come from the mapped tokens. */
.heyhy-root .heyhy-panel {
  right: var(--ai-launcher-inset);
  bottom: calc(var(--ai-launcher-inset) + var(--ai-launcher-size) + var(--ai-launcher-gap));
  /* Grows out of the launcher's corner with the same overshoot the app's own
     panel used, so it opens as one gesture with the button that opened it. */
  transition: opacity 160ms ease, transform 220ms cubic-bezier(0.34, 1.36, 0.64, 1);
}
.heyhy-root .heyhy-panel[data-collapsed="true"] { transform: translate(10px, 14px) scale(0.86); }
@media (prefers-reduced-motion: reduce) {
  .heyhy-root .heyhy-panel { transition: none; }
}
/* .ai-panel.is-fullscreen: the origin moves to the centre, because unfolding from
   a corner is the DOCKED gesture and fullscreen is not that. */
.heyhy-root .heyhy-panel[data-fullscreen="true"] { transform-origin: center; }

/* .ai-panel-head. Transparent, not a raised bar: the panel's own --panel is the
   header's background, and the 1px rule under it is the whole separation. The
   widget's --heyhy-surface-raised header (which is --panel-2, the assistant
   bubble's colour) reads as a toolbar the existing panel does not have. */
.heyhy-root .heyhy-header {
  padding: 14px 16px;
  background: transparent;
  gap: 0;
}
/* .ai-panel-wordmark. A window title at the app's own heading size — not the 700
   the package uses, which at 14px reads as a label rather than a name. */
.heyhy-root .heyhy-title-text {
  font-size: 15px;
  font-weight: 650;
  letter-spacing: -0.01em;
  color: var(--heyhy-text, var(--text));
}
/* The dark-first mark swap, exactly as .ai-mark-on-* does it: the reversed mark
   ships visible, and only the light theme swaps it. Reversing this shows the wrong
   mark for one frame on every load — and forever on the default. */
html[data-theme="light"] .heyhy-root .heyhy-mark-on-dark { display: none; }
html[data-theme="light"] .heyhy-root .heyhy-mark-on-light { display: block; }

/* .ai-panel-icon / .ai-panel-close: a 32px box on the app's small radius, and the
   hover is the brand tint every other icon control in this app uses — not the
   sunken surface, which here is the code-block colour. */
.heyhy-root .heyhy-iconbtn {
  width: 32px;
  height: 32px;
  border-radius: var(--radius-sm);
}
.heyhy-root .heyhy-iconbtn:hover { background: var(--primary-soft); color: var(--heyhy-text, var(--text)); }
.heyhy-root .heyhy-iconbtn[aria-pressed="true"] { background: transparent; }
.heyhy-root .heyhy-iconbtn[aria-pressed="true"]:hover { background: var(--primary-soft); }
.heyhy-root .heyhy-close { font-size: 22px; border-radius: var(--radius-sm); margin-right: -4px; }

/* .ai-messages */
.heyhy-root .heyhy-log { padding: 16px; gap: 12px; }

/* .ai-msg. 13.5px at 1.5, not the 14px base: the panel is 400px wide and this is
   the size the existing answers are read at. */
.heyhy-root .heyhy-msg {
  padding: 10px 12px;
  font-size: 13.5px;
  line-height: 1.5;
}
/* .ai-msg.user / .ai-msg.assistant. Both are OUTLINED cards in this app — the
   tint alone is not the boundary — and the assistant's is full-width, so a table
   or a diagram scrolls WITHIN the bubble instead of stretching the panel. */
.heyhy-root .heyhy-msg[data-role="user"] { border: 1px solid var(--heyhy-border, var(--line)); }
.heyhy-root .heyhy-msg[data-role="assistant"] {
  align-self: stretch;
  max-width: 100%;
  border: 1px solid var(--heyhy-border, var(--line));
}
/* .ai-msg.empty-hint: the first-open greeting is a centred caption across the
   panel, not a paragraph hugging the left edge. */
.heyhy-root .heyhy-note[data-role="greeting"] {
  padding: 10px 12px;
  text-align: center;
  font-size: 12.5px;
}

/* ── Rendered markdown inside an assistant bubble ─────────────────────────────
   The widget spaces answer content in `em` off its own 14px base; the app's own
   panel used a fixed 8px rhythm at 13.5px, and these are its rules in its order —
   the panel is deleted, so there is no second block above to keep in step with,
   and this is now the one place an answer's rhythm is set. */
.heyhy-root .heyhy-msg[data-role="assistant"] > :first-child,
.heyhy-root .heyhy-msg-body > :first-child { margin-top: 0; }
.heyhy-root .heyhy-msg[data-role="assistant"] > :last-child,
.heyhy-root .heyhy-msg-body > :last-child { margin-bottom: 0; }
.heyhy-root .heyhy-msg p { margin: 0 0 8px; }
.heyhy-root .heyhy-msg h1,
.heyhy-root .heyhy-msg h2,
.heyhy-root .heyhy-msg h3 { margin: 12px 0 6px; font-size: 14px; font-weight: 600; }
.heyhy-root .heyhy-msg ul,
.heyhy-root .heyhy-msg ol { margin: 0 0 8px; padding-left: 20px; }
.heyhy-root .heyhy-msg li { margin: 2px 0; }
.heyhy-root .heyhy-msg code { font-size: 12px; padding: 1px 5px; }
.heyhy-root .heyhy-msg pre {
  border: 1px solid var(--heyhy-border, var(--line));
  border-radius: var(--radius-sm);
  padding: 10px;
  margin: 0 0 8px;
}
.heyhy-root .heyhy-msg blockquote {
  margin: 0 0 8px;
  padding: 4px 12px;
  border-left: 3px solid var(--line-strong);
  color: var(--heyhy-text-muted, var(--muted));
}
/* .ai-msg .ai-table-wrap. `overflow-y: hidden` because a one-axis scroll
   container makes the other axis `auto` too, which produced a stray vertical
   scrollbar on tall tables; the padding is room for macOS's OVERLAY horizontal
   scrollbar, which is drawn on top of content at the container's bottom edge and
   otherwise sits on the last row's border. */
.heyhy-root .heyhy-table-wrap {
  max-width: 100%;
  overflow-y: hidden;
  padding-bottom: 5px;
  margin: 0 0 8px;
}
/* Cells inherit this (see the `1em` restatement further up), so it is the one
   place a chat table's text size is set. */
.heyhy-root .heyhy-table-wrap table { table-layout: auto; font-size: 12.5px; }
.heyhy-root .heyhy-table-wrap th,
.heyhy-root .heyhy-table-wrap td {
  padding: 5px 8px;
  /* `break-word`, not the package's `anywhere`: both break a word that cannot
     fit, but `anywhere` also volunteers every character as a break opportunity,
     which is how "AV118" became "AV11 8" in a narrow column. */
  overflow-wrap: break-word;
}
/* The header sits on --panel, one step up from the code-block surface the package
   uses — the same relationship .ai-msg th has to its bubble. */
.heyhy-root .heyhy-table-wrap th { background: var(--heyhy-surface, var(--panel)); font-weight: 600; }

/* .ai-progress / .ai-note: the working state's quiet register. 11px for the
   stopwatch, 12px for the phrase — the package sizes both off its own 0.92em. */
.heyhy-root .heyhy-status { font-size: 12px; }
.heyhy-root .heyhy-elapsed { font-size: 11px; }

/* .ai-form / .ai-input / .ai-send / .ai-mic. The composer sits on the panel's own
   background with a 1px rule above it, the box is the deep surface at the app's
   small radius, and both buttons are 38px squares on that same radius. */
.heyhy-root .heyhy-form { padding: 12px; background: transparent; }
.heyhy-root .heyhy-input {
  background: var(--heyhy-surface-sunken, var(--bg-deep));
  border-radius: var(--radius-sm);
  padding: 9px 11px;
  font-size: 13.5px;
  line-height: 1.4;
  max-height: 140px;
  min-height: 0;
}
.heyhy-root .heyhy-input:focus-visible { outline: 2px solid var(--heyhy-accent, var(--primary)); outline-offset: 0; border-color: var(--heyhy-accent, var(--primary)); }
.heyhy-root .heyhy-send { border-radius: var(--radius-sm); }
.heyhy-root .heyhy-mic {
  /* Width and height restated: `.heyhy-root .heyhy-iconbtn` above pins the header
     box at 32px and outranks the package's own `.heyhy-mic { width: 38px }`, so
     without these the input row's mic shrinks to a header control. */
  width: 38px;
  height: 38px;
  background: var(--heyhy-surface-sunken, var(--bg-deep));
  border-radius: var(--radius-sm);
}
.heyhy-root .heyhy-mic:hover { background: var(--heyhy-surface-sunken, var(--bg-deep)); }
/* Recording. The widget already draws the fill, the ring and the pulse from
   --heyhy-danger; this only keeps the deep box from the rule above out of the way,
   since .heyhy-mic and the recording rule sit at the same specificity and the
   host's comes later. */
.heyhy-root .heyhy-mic[data-recording="true"],
.heyhy-root .heyhy-mic[data-recording="true"]:hover {
  background: color-mix(in srgb, var(--heyhy-danger, var(--error)) 14%, transparent);
}

/* .ai-panel.is-fullscreen. Text scaled for reading at arm's length rather than
   glancing at a sidecar, and the conversation column centred on a wide screen
   instead of stranded against the left edge of a 1400px window — done with
   padding, because centring the flex items would fight `align-self: flex-end` on
   the user's own turns. */
.heyhy-root .heyhy-panel[data-fullscreen="true"] .heyhy-msg { font-size: 15px; line-height: 1.55; max-width: min(92%, 900px); }
.heyhy-root .heyhy-panel[data-fullscreen="true"] .heyhy-msg[data-role="user"] { max-width: min(80%, 620px); }
.heyhy-root .heyhy-panel[data-fullscreen="true"] .heyhy-log { padding: 22px max(22px, (100% - 1000px) / 2); }
.heyhy-root .heyhy-panel[data-fullscreen="true"] .heyhy-input { font-size: 15px; }
.heyhy-root .heyhy-panel[data-fullscreen="true"] .heyhy-table-wrap table { font-size: 13.5px; table-layout: auto; }
.heyhy-root .heyhy-panel[data-fullscreen="true"] .heyhy-table-wrap th,
.heyhy-root .heyhy-panel[data-fullscreen="true"] .heyhy-table-wrap td { padding: 7px 10px; }
