/* 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-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 quiet states have
     their own tokens. Pins are flat and opaque, so quiet states must read as
     quiet by colour alone. */
  --pin-offline: #587481;
  /* Ring on a pin whose bus sends every live panel reading (Full-data pins). */
  --pin-full-data: #045218;
  --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);

  /* Font stacks shared across the app. The `.heyhy-root` block keeps its own
     literal copies; the widget parity test pins them 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;

  /* Height of every interactive control on a filter/action row (buttons,
     multi-select trigger, selects, text inputs, inline checkbox labels). Each
     takes it as `min-height` and centres its content; the token, not padding,
     sets the height. Geometry, not themed. */
  --control-h: 38px;

  /* Filter-bar chevron for controls that cannot hold an inline <svg>; same
     geometry as the .group-multi-caret markup. Used as a MASK, so the stroke
     colour is irrelevant: the visible colour is `currentColor` at the use site.
     Not themed. */
  --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 by
     it and the rail takes it as its width, so a collapse is one variable change.
     The collapsed width is twice the icon's centre line (12px rail padding +
     12px item padding + 10px half of a 20px glyph = 34px), which centres the
     glyph in the icon-only rail. Change any of those paddings and this number
     must 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 header by app.js (the header is content-sized and its
     filter row wraps at some widths). This value is only the first-frame
     fallback. */
  --topbar-h: 62px;

  font-synthesis: none;
}

/* One token so a coarse pointer resizes every control that honours it. 38px
   suits a mouse; HIG and Material ask for 44 on touch. A sibling rule rather
   than a nested @media inside :root, so it needs no CSS Nesting support. */
@media (pointer: coarse) {
  :root { --control-h: 44px; }
}

/* Collapsed is stored on <html> (set pre-paint in index.html so the rail never
   flashes at the wrong width), since <body> 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 {
  /* Reserve the page scrollbar's gutter whether or not it is showing, so
     filtering a list past the fold does not shift centred, max-width containers
     sideways. A no-op where scrollbars overlay (macOS default). */
  scrollbar-gutter: stable;
}
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 as the nav rail, so topbar and rail read as one piece
     of chrome. Flat and opaque in both themes. --panel sits only one step off
     --bg (about 3% in light), so the chrome's OUTER edge keeps its border to
     separate it from content; the seam removed below is the internal one. */
  background: var(--panel);
  position: sticky;
  top: 0;
  /* Above Leaflet's controls (z-index 1000) so the map never overlaps the
     sticky topbar. */
  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 carries .brand's flex
   layout itself. */
.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, so the reset is restated here. */
.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 matched to the brand title's
   cap-height. The <img> is alt="" aria-hidden because the title text
   announces the app. */
.brand-logo {
  height: 28px;
  display: block;
  user-select: none;
  pointer-events: none;
}
.brand-logo-img {
  height: 28px;
  width: auto;
  display: block;
}
/* Dark is the default theme (:root is dark; [data-theme="light"] overrides),
   so the reversed lockup is the default and light overrides it. */
.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. Height matches .brand-logo-img
   so header height is the same with or without it; width is capped and
   `object-fit: contain` letterboxes. `display: none` is restated for [hidden]
   because this rule sets `display` and would outrank the attribute. */
.brand-cobrand {
  display: flex;
  align-items: center;
  gap: 12px;
  flex: 0 1 auto;
  min-width: 0;
}
.brand-cobrand[hidden] { display: none; }
/* A 1px hairline in --line, the token every other chrome seam 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 beside the brand title. Warn token + tinted
   background reads in both themes without 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;
}

/* Dev-environment chip after the brand (painted by app.js on a dev host). The error
   token marks it as unlike the amber "Internal testing" pill; same pill height, so
   the header does not grow. The long wording only shows where there is room. */
.env-chip {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  height: 20px;
  padding: 0 10px;
  border-radius: var(--radius-pill);
  background: color-mix(in srgb, var(--error) 18%, transparent);
  color: var(--error);
  border: 1px solid color-mix(in srgb, var(--error) 45%, transparent);
  font-size: 10.5px;
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  white-space: nowrap;
}
.env-chip-long { display: none; }
@media (min-width: 1100px) {
  .env-chip-long { display: inline; white-space: pre; }
}

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;
  /* Single line with ellipsis so long emails do not grow the topbar past the
     slideover's hardcoded top: 56px; the full identity is in the dropdown panel.
     The cap fits a 30-char email without crowding the bell + theme toggle. */
  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); }

/* Position matters: this block must stay below the header component rules,
   specifically below `.user-trigger`'s `max-width: 320px`. Media queries add
   no specificity, so the override only wins by coming later in the file. */
/* Narrow viewports: the header flexbox does not wrap, so its children must fit
   or the document grows wider than the screen. Chrome then widens the layout
   viewport and `position: fixed` elements (chat launcher, side-menu overlay,
   modal backdrop, session-expired overlay) resolve against it and land
   off-screen. Drop the pill and tighten the trigger below ~640px. */
@media (max-width: 640px) {
  .brand-pill { display: none; }
  /* The co-brand goes with the pill: two lockups plus the trigger plus the bell
     overflow a phone. Ours identifies the app, so it stays. */
  .brand-cobrand { display: none; }
  header { gap: 10px; padding: 10px 12px; }
  /* .brand and .brand-link are `flex: 0 0 auto`; min-width: 0 lets the title
     ellipsis instead of widening the row. */
  .brand, .brand-link { min-width: 0; }
  .brand-logo-img { max-width: 76px; height: auto; }
  .brand-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  /* Proportional rather than fixed; the full address is in the panel the trigger
     opens. */
  .user-trigger { max-width: 34vw; }
}

.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), three across. The chip's three
   colours are set inline per swatch by renderPresetPicker() from THEME_PRESETS
   for the current mode (background = preset --panel, border = --line-strong,
   `color` = --primary, picked up by the inner square as currentColor). Hover,
   focus, the selected ring and the label use the ACTIVE theme's tokens. */
.preset-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  /* Equal-height cells: one label wraps to two lines ("High contrast"), so the
     ring and hover fill would otherwise differ in height. */
  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 shown, so the user can
   see what is on offer (.preset-locked-note says why it is locked). */
.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 repeatedly; the badge count is the last-known value.
   Greyed so it does not read as current. */
.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 in the inline editor; hidden so the subscription does not appear twice.
   Restored on Cancel/Save. */
.subs-list li.is-editing { display: none; }

/* Inline subscription editor: lives in each .subs-section between the header
   and the rows; hosts the per-kind fields in [data-slot="kind-fields"]. */
/* Opens via grid-template-rows 0fr→1fr: 1fr resolves to the form's natural
   height, so a tall form is never clipped by a max-height cap. */
.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-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-load-error { margin: 14px; }
.subs-loading { padding: 14px; }
.picker-empty { padding: 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;
}

/* 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 (reuse .rs-chip). 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;
}

.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 aria-checked
   (toggled by JS); not redefined 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);
  /* Room for ~14 rows at a 600px viewport, then scrolls; clamp() lets it shrink
     on short screens. dvh, not vh: on mobile Chrome/Edge `vh` is the
     address-bar-hidden height, so a `vh` box overflows while the bar shows. The
     vh line is the fallback for browsers without dvh. */
  max-height: clamp(220px, 50vh, 540px);
  max-height: clamp(220px, 50dvh, 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, Opportunity
   picker and VOR toggle, so it takes --control-h. .search-wrap needs no height
   of its own: #bus-search is its only in-flow child (.search-results is
   absolute), so `top: calc(100% + 6px)` hangs off the field's bottom. */
#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); }
.search-results {
  position: absolute;
  top: calc(100% + 6px);
  left: 0;
  right: 0;
  /* A result row (and the Salesforce fallback's tag line) needs more than the
     ~160px desktop box to sit on one line. Capped to the viewport so the
     full-width phone layout cannot overflow. */
  min-width: min(320px, calc(100vw - 32px));
  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);
  /* Above the topbar (z-index: 1500) so the sticky header does not clip the
     dropdown. */
  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;
}
/* Salesforce fallback rows (api/vehicle-search): vehicles the fleet search did
   not find. A section head, then result rows with one extra line saying why the
   row differs. */
.search-section-head {
  padding: 8px 10px 4px;
  border-top: 1px solid var(--line);
  color: var(--muted);
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}
.search-result.is-sf {
  grid-template-areas:
    "name reg"
    "job  job"
    "vin  vin"
    "tag  tag";
}
.search-result .sr-tag { grid-area: tag; color: var(--muted); font-size: 11px; }

/* "No telemetry received yet": a Salesforce-only vehicle, on the sidebar and
   the vehicle page. --info tints the frame only; the text stays --text so it
   reads in both themes. */
.no-telemetry-note {
  border: 1px solid color-mix(in srgb, var(--info) 55%, transparent);
  background: color-mix(in srgb, var(--info) 14%, transparent);
  border-radius: var(--radius-sm);
  padding: 10px 12px;
  margin-bottom: 12px;
  color: var(--text);
  font-size: 13px;
  line-height: 1.4;
}
.no-telemetry-note[hidden] { display: none; }
.no-telemetry-note strong { display: block; margin-bottom: 2px; }

.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 both .group-multi (createSearchableSelect), so
   neither needs a skin here. `.operator-filter` / `.opp-filter` in the markup
   are naming hooks only and carry no rules. */

/* Below the tablet breakpoint the filter row wraps and the pickers give up
   their 220px floor. .filter-bar is `display: flex` with no wrap and each
   .group-multi has `min-width: 220px`, so without this the bar overflows the
   right edge on a phone. 150px fits "All operators" plus the caret, and two fit
   side by side on a 360px screen. */
@media (max-width: 900px) {
  .search-wrap { flex: 1 1 100%; min-width: 0; max-width: none; }
  .filter-bar { flex-wrap: wrap; width: 100%; row-gap: 8px; }
  .filter-bar .group-multi { min-width: 0; flex: 1 1 150px; }
}

/* ──────────── 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. Where it wraps to its own row (below
     ~1250px) auto would pin it right while the search stays left; where they
     share a row, left-alignment simply places it after the search box. */
}

/* ──────────── Top stat cards ──────────── */
.topcards {
  display: grid;
  /* 180px min keeps the cards on one row inside main's 1280px max-width (1232px
     after padding); auto-fit wraps them on narrower viewports. This is the
     sub-1101px / non-dashboard shape; the wide dashboard shell re-declares it 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) ────────────
   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 with no page scroll. Gated on
   body.view-dashboard (set/cleared in renderCurrentView(), cleared in
   showLogin()).

   Gated at >=1101px wide AND >=640px tall: below that the cards and map cannot
   both fit, and the shell turns page scrolling off, so the stacked scrolling
   layout applies instead (fixed overhead is ~146px: header + view padding +
   controls row + grid gap).

   The header height is never hardcoded (it is sticky and content-sized): the
   body is a flex column and the view takes flex: 1 1 auto.

   INVARIANT: an in-flow child added directly to <body> would take height from
   the dashboard. Slideovers/overlays are fine (out of flow). */
@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). */
    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 `[hidden] { display: none }`, so the grid
     above would keep the dashboard painted on every other view; restated here
     (same trap as .fleet-detail). */
  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 */
  }

  /* Takes its 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. */
  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 so the filters read as
   belonging to the map. Implemented as an empty first grid cell, so the indent
   is the 320px card track + 16px gap by construction.

   Why 1440: the filter bar's natural width is 657px and cannot shrink, and the
   map column is `viewport − 616`. Below ~1273px the bar is wider than the
   column and body.view-dashboard's overflow: hidden would clip the Opportunity
   picker; below ~1437px it fits but cannot share a line with the search, so the
   row wraps and costs ~52px of map height. At 1440px the column is 824px and
   657 + 14 gap + 153 search fits on one line. Below it the row spans both
   columns. Not gated on the rail being collapsed. */
@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 search box's top. Safe for the `auto` first row: .topcards has
       `min-height: 0` and `overflow-y: auto` in this shell, so its min-content
       contribution is 0 and the span cannot grow the controls row. */
    grid-template-areas:
      "cards controls"
      "cards map";
  }
  /* It is the flex BASIS that must come down, not the min-width: with
     `flex-wrap: wrap`, line breaking uses base sizes before any shrinking.
     flex-grow is kept, so this is a floor, not a width; the search grows back to
     its 420px max on wide screens. At 1440px it is a 153px box (~18 characters). */
  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, 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,
   currentColor), so it follows both themes; `line-height: 1` keeps the 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 is the button's (hit element is
   always #card-fleet). That stops the rows' native `title` from showing, since
   Chrome finds no title on the button; .fs-item takes pointer-events back below
   so it can be hovered, and a click on it still bubbles to the button. */
.card-fleet > * { pointer-events: none; }
.card-fleet:hover { border-color: var(--line-strong); }
.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. `cursor` is left alone: it inherits `pointer` from
     the card, which 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. Same warn token + tinted
   background as .brand-pill, so it reads in both themes. Hidden when the feed
   is current. */
.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;
}
/* The `display: block` above outranks the UA `[hidden]` rule; this restores it.
   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. `:empty` collapses it 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 the card's overflow and
   stacking cannot clip it. Same panel/line/shadow recipe as .leaflet-tooltip.
   pointer-events: none is load-bearing: the tooltip sits over the card it
   describes and must not swallow its clicks. */
.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 rendered,
     and `visibility` transitions discretely alongside it. Hidden, it is also out
     of the accessibility tree. */
  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);
  /* Contains Leaflet's z-indices (controls go up to 1000) in this stacking
     context so they cannot paint over the topbar / slideovers. */
  position: relative;
  isolation: isolate;
  transition: filter 200ms ease;
}
/* Last refreshFleet() sweep was incomplete: markers are slightly desaturated
   with a dimmed border so they do not read as a live snapshot. This is the
   only stale signal the map carries. */
#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, contained by #map's
   isolation: isolate so it cannot 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); }
/* The button focus ring: elements here set no other outline, so one grouped
   rule covers them all. */
.map-fullscreen-btn:focus-visible,
.map-basemap-btn:focus-visible,
.rail-toggle:focus-visible,
.gf-filter-chip:focus-visible,
.faults-pill-btn:focus-visible,
button.fault-stat:focus-visible,
.report-about > summary: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; isolation: isolate keeps it below the
   topbar). Same 32px square, border/fill and 10px inset as .map-fullscreen-btn,
   at the other end of the same right edge, so the two read as a pair. Icon-only:
   the words are in the tooltip + aria-label, and attachBasemap() moves Leaflet's
   attribution 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.
     The bottom-right corner belongs to the HeyHy! launcher; keeping it to a
     single control avoids a second `cursor: pointer` target in the approach path.
     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); }
/* A hair lighter than the fullscreen button's stroke: the globe's equator and
   meridian cross in a 16px box, and at 2px the crossing fills in as a blob. */
.map-basemap-btn svg { height: 16px; width: 16px; display: block; stroke-width: 1.6; }
/* One icon at a time. aria-pressed means "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. */
#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; earlier it is
   :-webkit-full-screen. Kept as its OWN block, never a selector list with the
   standard rule: a browser that rejects one selector in a list drops the whole
   rule. */
#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 shows the last-known value because the latest refresh failed.
   bindError() applies this plus a `title` tooltip; the recoverAfterResume()
   retry loop clears both when 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): same tokens as
   the popup above instead of Leaflet's stock white box, which reads as a hole
   on the dark map. The arrow is a CSS triangle (a BORDER colour), so each
   direction needs its own rule; Leaflet flips direction near a map edge. 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; the button is opaque and
   above it (z-index 1001 vs the 1000 control pane). The ~390px Esri credit would
   vanish under the button on a narrow map. Cap the CORNER BOX, not the credit:
   a percentage on the credit resolves against this shrink-to-fit box, while the
   box's own percentage resolves against .leaflet-control-container (the map). */
.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;
  /* dvh, with vh as the fallback: on mobile Chrome/Edge `vh` is the
     address-bar-hidden height, so the drawer would overhang the screen. */
  height: calc(100vh - 56px);
  height: calc(100dvh - 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; the drawer must
     win so they do not leak over the panel. */
  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 beside the main bd-label, spelling out triplet-value formats
   (e.g. "avg / max / min"). */
.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, channels empty). No
   animation, so the dot reads as a status, not a heartbeat. */
.pulse.is-stale {
  background: var(--warn);
  box-shadow: none;
  animation: none;
}
/* Every channel of the last fetchCurrent() threw: telemetry API 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);
}

/* ── Dashboard cards (.topcards), matched to the vehicle panel's cards ──
   Same type scale as #bus-detail: 11px uppercase labels, bold numbers, 12.5px
   notes. In dark mode the card takes the same lifted fill as .bd-card; in light
   mode it stays --panel (white), which already sits off the page background. The
   VOR stat captions keep their size: they must stay on one line at every card
   width (see .card-vor-stats). */
:root:not([data-theme="light"]) .topcards .card {
  background: color-mix(in srgb, var(--panel) 94%, var(--text));
}
.topcards .card h3,
.topcards .fs-label { font-size: 11px; }
.topcards .fs-value { font-weight: 700; }
.topcards .card .big { font-size: 20px; font-weight: 700; }
.topcards .card > .small { font-size: 12.5px; }
.topcards .card-vor-link { font-size: 12.5px; font-weight: 600; }
/* Two figures side by side (electric | hydrogen), left-aligned under the title like
   the single-figure cards; the type scale is the .big / .small rules above. */
.card-pair {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 4px 10px;
}
.card-pair > div > .big,
.card-pair > div > .small { display: block; }

/* ── Map vehicle panel (#bus-detail): cards ──
   Identity, telemetry tiles, the charge card, min/avg/max cards and the software
   card. Scoped to #bus-detail: the warranty and fleet slideovers share .bus-detail
   and .bd-row and keep their plain rows. A card is the panel lifted by a few
   percent of the text colour, so it reads in every theme. */
#bus-detail header h3 { font-size: 18px; }
#bus-detail header #bd-sub,
#bus-detail header .bd-route-line { font-size: 13px; }
#bus-detail .bd-body { gap: 10px; }
#bd-live-section,
#bd-battery-section { display: flex; flex-direction: column; gap: 10px; }
#bd-live-section[hidden],
#bd-battery-section[hidden] { display: none; }
#bus-detail #bd-fullpage {
  display: block;
  width: 100%;
  margin: 0 0 2px;
  text-align: center;
  font-size: 13.5px;
  font-weight: 700;
}
#bus-detail .bd-section-head {
  justify-content: flex-start;
  gap: 10px;
  margin: 10px 0 0;
  font-size: 12px;
  font-weight: 700;
  letter-spacing: 0.14em;
  color: var(--text);
}
.bd-card {
  background: color-mix(in srgb, var(--panel) 94%, var(--text));
  border: 1px solid var(--line);
  border-radius: var(--radius);
  box-shadow: var(--shadow-card);
}
.bd-id-card { padding: 6px 14px; }
.bd-id-card .bd-row { padding: 6px 0; }
.bd-id-card .bd-val { min-width: 0; text-align: right; overflow-wrap: anywhere; }
.bd-id-card .bd-val.mono { color: var(--text); }
.bd-tiles {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 8px;
}
.bd-tile {
  display: flex;
  align-items: center;
  gap: 10px;
  min-width: 0;
  padding: 12px 14px;
}
.bd-tile > div { min-width: 0; }
.bd-tile-icon { flex: 0 0 auto; width: 22px; height: 22px; color: var(--text); }
.bd-tile-icon.is-accent { color: var(--good); }
.bd-tile-label { font-size: 12.5px; color: var(--muted); }
.bd-tile-val {
  margin-top: 2px;
  font-size: 15px;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.bd-fuel-card {
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: 14px 16px;
}
.bd-fuel-head {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 10px;
}
.bd-fuel-val { font-size: 20px; font-weight: 700; font-variant-numeric: tabular-nums; }
.bd-fuel-card .bd-bar {
  height: 10px;
  margin: 0;
  border-radius: var(--radius-pill);
  background: color-mix(in srgb, var(--text) 14%, transparent);
}
.bd-fuel-card .bd-bar-fill { background: var(--good); border-radius: inherit; }
.bd-tri { overflow: hidden; }
.bd-tri-title {
  padding: 11px 16px;
  border-bottom: 1px solid var(--line);
  font-size: 14px;
  font-weight: 600;
}
.bd-tri-cols {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  padding: 12px 0 14px;
}
.bd-tri-col { min-width: 0; text-align: center; }
.bd-tri-col + .bd-tri-col { border-left: 1px solid var(--line); }
.bd-tri-label {
  font-size: 10.5px;
  font-weight: 600;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--muted);
}
.bd-tri-val {
  margin-top: 4px;
  font-size: 17px;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
.bd-sw-card { display: flex; gap: 14px; padding: 14px 16px; }
/* display:flex would otherwise beat the hidden attribute. */
.bd-sw-card[hidden] { display: none; }
.bd-sw-icon { flex: 0 0 auto; width: 26px; height: 26px; color: var(--good); }
.bd-sw-body { flex: 1 1 auto; min-width: 0; }
.bd-sw-head {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 8px;
  margin-bottom: 4px;
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--muted);
}
.bd-sw-card .bd-row { padding: 6px 0; border-bottom: 1px solid var(--line); }
.bd-sw-card .bd-row:last-child { border-bottom: 0; }
.bd-sw-card .bd-label {
  font-size: 13px;
  font-weight: 500;
  letter-spacing: 0;
  text-transform: none;
  color: var(--text);
}
.bd-sw-card .bd-val.mono { color: var(--text); }
/* Green up arrow left of a software version that changed in the last week. The
   date and previous version are its tooltip (ui/hover-tip.js), so the versions
   stay right-aligned with the rest of the column. */
.sw-up {
  display: inline-flex;
  vertical-align: -1px;
  margin-right: 5px;
  color: var(--good);
  cursor: default;
  border-radius: 2px;
}
.sw-up:focus { outline: none; }
.sw-up:focus-visible {
  outline: 2px solid var(--primary);
  outline-offset: 2px;
}
.sw-up-icon {
  width: 11px;
  height: 11px;
  fill: none;
  stroke: currentColor;
  stroke-width: 3;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* Group cell in the stationary report: group lists can be long; capped width,
   tooltip shows 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;
}

/* Percentage column widths so all 8 columns stay inside .table-wrap on laptop
   viewports; long values (group, geofence) ellipsize and keep the full text in
   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;
}
/* Lets the label truncate with an ellipsis instead of running under the sort
   indicator; without min-width:0 the inline-block will not shrink in narrow
   table-layout:fixed columns. */
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 on the <ul>: both live inside the items, so
     every row is edge-to-edge and the cursor stays `pointer` all the way down the
     rail (a container strip has no `cursor: pointer`). The visual inset is drawn
     by each item's ::before (see .side-menu-item). The vertical 12px is the gap
     to the head/foot. */
  padding: 12px 0;
  display: flex;
  flex-direction: column;
  gap: 0;
  flex: 1 1 auto;
  overflow-y: auto;
  /* The labels are `nowrap`; this clips them while the rail narrows 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 <ul>'s 12px + the item's own 12px. The glyph's centre lands on 34
     (which --rail-w-collapsed is derived from) while the button's box 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 spans the full rail width to give the cursor a
   continuous target, and this pill is the paint. `inset: 1px 12px` gives 12px
   each side and 1px top/bottom (a 2px gap between adjacent rows). z-index: -1
   puts it behind the icon and label without positioning them (sub-items are bare
   text nodes); .side-menu is the stacking context, so it 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. It also makes the collapse a pure clip: the icon holds its x. */
.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;
  /* No padding: a container strip would drop the cursor back to an arrow. Each
     sub-item's ::before 1px top/bottom already separates the pills by the same 2px
     as the top-level rows. */
  padding: 0;
  display: flex;
  flex-direction: column;
  /* No flex gap: a gap is a strip of the CONTAINER with no `cursor: pointer`, so
     the arrow would return between sub-items. */
  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 the line box's
     baseline strut makes the <li> a pixel taller than the button, leaving a dead
     row under every sub-item. (Top-level items are `display: flex`, 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 under its group's label. Right side is 24 likewise. */
  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, which has 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 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 is a permanent column the content sits beside;
   below it, an overlay drawer. This is the only 900px breakpoint (the other
   900px values are `max-width` caps).

   EVERY rule here is gated on `:root.rail-on`, which app.js sets from
   syncRailVisibility() only when a rail is on screen. Width alone is not enough:
   the rail is hidden for a signed-out session, and gating the <body> padding on
   width alone would reserve 240px of blank gutter on the sign-in page. Width
   says "a rail would fit"; rail-on says "there is one".

   The rail stays `position: fixed`. The dashboard shell (`body.view-dashboard`
   flex column) forbids new IN-FLOW children of <body>; a fixed rail is out of
   flow and the offset is carried by padding. Do not turn this into a body grid:
   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, so the brand lockup holds the same x
     at every rail width. width:100% resolves against body's content box
     (viewport − rail), so the rail width is added back and the negative margin
     pulls the left edge to x=0; both animate on the padding's clock. Gated on
     rail-on with the padding it compensates for: without it the negative margin
     would hang a signed-out topbar 240px off the left edge. */
  :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 full-bleed bar's border-bottom would run across the top
       of the rail. The bar paints ABOVE the rail (z-index 1500 vs 1400), so the
       border is removed and redrawn only where it still means something. */
    border-bottom-color: transparent;
  }
  /* The chrome/content boundary, redrawn as the bar's bottom edge only to the
     right of the rail. With the rail's border-right it traces one continuous L
     around the chrome; `left` rides --rail-track 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 -1px
     lands exactly over the transparent border it replaces. That border is made
     transparent, not removed, because its 1px of layout keeps --topbar-h stable. */
  :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" + ×). The rail has nothing
     to close; its collapse control lives in the foot. */
  :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
       load and on every resize across the breakpoint. */
    animation: none;
    /* Under the sticky topbar (z-index 1500) and under the drawer's own 1800. */
    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 first; collapsing has no delay, so the text is
     gone before the width closes over it. `visibility` removes faded labels from
     the tab/screen-reader order; opacity alone would leave them focusable. */
  :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. */
  :root.rail-on[data-rail="collapsed"] .side-menu-sublist { display: none; }
  /* Centre the toggle on the icon column. */
  :root.rail-on[data-rail="collapsed"] .side-menu-foot { justify-content: center; }
}

/* The rail's width, the body padding and the topbar compensation are one motion,
   so they are switched off together; collapsing still works, instantly. The
   selectors mirror the gated ones above CHARACTER FOR CHARACTER (including the
   `:root.rail-on[data-rail="collapsed"]` pair): a media query adds no
   specificity, so a bare `body { transition: none }` would lose to
   `:root.rail-on body`; matching the selector and coming later wins. */
@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;
}

/* Steps the width ceiling up on larger viewports so wide tables (the 14-column
   fixed-layout Warranty table) are not forced to ellipsis their own headers. All
   three selectors are repeated (matching their original specificity) so none is
   left behind by a less-specific rule. #login-view is excluded: a sign-in box in
   a 1720px container reads as broken, so it keeps the 1280px measure. */
@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 outranks the UA `[hidden] { display: none }`, so without
   this guard the attribute changes the DOM and nothing on screen (the operator
   picker would stay visible for an operator-scoped user). A test asserts 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 multi-select trigger beside it.
   .report-controls is align-items: flex-end, so unequal heights would show as a
   small stagger along the top of the row. */
.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 .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 the native spinner only when there is no value, so the placeholder reads
     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). On its own line with a top separator so
   it reads as the footer of the filter card regardless of viewport width. */
.report-control-actions {
  flex: 1 1 100%;
  flex-direction: row;
  /* Wrap rather than clip: this row grows (count, status, the VOR tick box
     beside Export CSV), and a narrow viewport would push the rightmost button off
     the panel edge. */
  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, so this actions row is the only child of its .report-controls. Keyed
   on :first-child rather than a VOR-specific class so it stays correct if that
   report gains filters. */
.report-control-actions:first-child {
  padding-top: 0;
  margin-top: 0;
  border-top: 0;
}
/* padding-block is a floor: the height comes from --control-h (38px).
   inline-flex + centring makes the min-height 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 carry an author-origin `display` (inline-flex), which
   outranks the UA `[hidden] { display: none }` (same trap as
   .bd-route-line[hidden]). Without this, #ge-subscribe (toggled by
   `subBtn.hidden = !sel.value`) shows with "All geofences" selected and is a
   dead button. */
.btn-primary[hidden],
.btn-secondary[hidden] { display: none; }

/* An <a> wearing .btn-secondary inherits the page line-height, which a <button>
   ignores, so `line-height: normal` keeps the link level with the other controls.
   Centring and --control-h come from .btn-secondary; this modifier holds only
   what is 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 inside table rows. min-height is
   reset deliberately: --control-h is the height of a control on a filter/action
   ROW, and inheriting it would defeat 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, recoloured to signal a
   removal. Hover stays subtle so the row does not fight the adjacent Edit button. */
.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, shared by the 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%;
  /* Never wider than the viewport; the floor under the flip below, for when
     neither edge can hold a 360px panel. */
  max-width: min(360px, calc(100vw - 24px));
  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; }
/* Set by positionGroupMultiPanel() (app.js) at open time when the panel
   anchored at the trigger's LEFT edge would run past the viewport's right edge;
   it then grows leftwards from the trigger's right edge. Not cosmetic: open()
   focuses the search box, and focus scrolls it into view even on an
   overflow: hidden container (#dashboard-view scrolls sideways and slides the
   KPI cards under the fixed rail; below the shell's 640px height floor the page
   itself scrolls). Same specificity as `.geofence-operator-filter
   .group-multi-panel` below, which right-anchors unconditionally, so this class
   is never added to it. */
.group-multi-panel.is-flipped { left: auto; right: 0; }
.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, like .btn-sm and .wmap-close: this is panel-internal
   furniture, and 38px filter boxes would bloat every popover that has one.
   Needed explicitly because pickers inside .report-controls sit within a
   .report-control, so `.report-control input[type="search"]` (0,2,1) reaches
   them. The leading .group-multi is load-bearing: without it this selector is
   (0,2,1), a tie with that rule that wins only by source order. .group-multi-panel
   is always a child of .group-multi, so the extra class gives a real (0,3,1). */
.group-multi .group-multi-panel input.group-multi-search {
  min-height: 0;
  padding: 6px 10px;
  /* Restates the 12.5px for the same reason: the .report-control rule's font-size
     reaches in here too. One popover, one size, wherever its trigger lives. */
  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;
}
/* The border-top separates the list from the search box + Clear row above it. A
   non-searchable picker (searchable:false) has neither, so the rule would be a
   stray line. */
.group-multi-panel > .group-multi-list:first-child { border-top: 0; padding-top: 0; }
.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 because the row also holds the checkbox, which stays at 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 */
/* A control inside .report-controls, so it takes --control-h like the selects
   and pickers on the same row; otherwise `align-items: flex-end` leaves its
   label 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;
}
/* Fixed layout with percentage column widths so the 9 columns stay 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 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;
}

/* ──────────── 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 of the controls, so its 4px bottom margin
   would show as drift. Zeroed here rather than on .report-title, which other
   reports use under a sub-line. */
.geofences-head .report-title { margin-bottom: 0; }
/* Every child is a single --control-h control, so centring is safe and keeps a
   wrapped second line reading as a row. */
.geofences-actions { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
/* A wrapper around the picker: it carries the narrower min-width and the
   right-anchored panel override below, which do not belong on the shared
   .group-multi. */
.geofence-operator-filter {
  display: flex;
  flex-direction: column;
  /* Narrower than the 220px .group-multi default so the action row 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. This one is the LAST
   item of a right-aligned action row, where a left-anchored panel runs ~116px
   past the viewport at 1280px and adds a horizontal scrollbar. Opening leftwards
   keeps it inside the page. */
.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;
   `justify-content: space-between` would strand the count in the middle with
   three children. */
.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, with the head's
   uppercase/letter-spacing undone. */
.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);
}
/* Operator-scope users get a read-only page (see applyScopeControlGating); this
   chip carries no author `display`, so the UA [hidden] rule works on it unaided. */
/* Occupancy badge on a list row, beside the name in the name column's grid cell;
   the name element 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 cannot 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: actions are de-emphasised when the row is idle so the name
   gets the full width; hover, focus or selection restores them. Touch devices
   (no hover) keep them visible. */
@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 drawing instructions and the edit-actions pill share
     one spot, clear of the top-right search and top-left zoom controls. Lifted
     above the bottom band, which holds the attribution credit (bottom-left, up to
     ~39px tall when wrapped) and the basemap button (bottom-right). The max-width
     cap stops a long instruction stretching to the edges. */
  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 are set as text with blank lines, so newlines
     must survive. No effect on 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;
}
/* Pasted rows. Monospace so delimited columns visibly line up; nowrap + scroll
   because wrapping a wide row makes it unreadable. */
.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[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 does not overlap Leaflet's zoom controls (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: this field is internal to the floating search shell over
   the map (borderless, sharing one bordered container with two icon buttons), so
   the compound is the control. The shell's height is 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 with the native select chrome removed, so the role dropdown reads
   as part of the app. Two 5×5 gradient boxes paint a chevron-down: the left fills
   the bottom-right triangle, the right fills the bottom-left, meeting 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-edit-name { min-width: 160px; width: 100%; }
#users-view .users-edit-role option {
  /* The native option list uses the OS palette; pinned 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 a
   privilege; a loud pill on most rows would hide the notable ones (operator, not
   set). */
#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 is marked as a fault. The token is
   --error (there is no --danger; an undefined var makes color-mix invalid and
   drops the pill). */
#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. */
.users-alias-list {
  font-size: 12px;
  margin-top: 2px;
}
.users-alias {
  /* A dotted underline rather than a pill, so extra addresses do not out-weigh the
     primary above them; it also hints that a title is 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 per row, so the two addresses can be compared side by side. */
.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 outranks the UA `[hidden] { display: none }`, so the
   banner would paint empty (it ships hidden and is filled once /v1/users
   answers). Any rule that sets `display` on an element that toggles `hidden`
   must restore it (same as .bd-route-line[hidden]). */
.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; 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. --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 (same trap as the Users banner). */
.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 does the same job
   (choose whose fleet you are looking at), and the other controls in this popover
   are segmented .seg groups, so there is no sibling select to match. Every value
   below comes from .group-multi-trigger; change them together. */
.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, not :focus, so
   a mouse click does not ring it. */
/* 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. `data:` is permitted by img-src in
   staticwebapp.config.json, which 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. */
  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. On the dashboard panel it
   sits under the type line and matches it (.muted); on the vehicle page it is an
   ordinary "Route" row and inherits .vh-val. */
.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, so this only stays within the muted treatment both surfaces use. */
.bd-route-line.is-unmapped,
#vh-route.is-unmapped { color: var(--muted); }

/* Kept explicit so a future `display` change cannot resurrect a hidden route. */
.bd-route-line[hidden] { display: none; }

/* Bus-detail push panel, 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 as their
   caption. */
.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 it, the item would be ~3 lines taller than its siblings
   and `align-items: flex-end` would push every control on its wrapped line down.
   flex-basis:100% gives it its own line. NO max-width, deliberately: flex
   line-breaking uses the hypothetical size, which a max-width CLAMPS, so a capped
   item fits beside Live status instead of taking its own line. Unconstrained it
   is the full 100% basis and always wraps; its ~1000px natural width still ends
   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 uses, so it reads as a chip but
   never as one of the four statuses. Both mixes are off --muted, not
   --line-strong, which vanishes at 0.22 alpha on --panel-2 (the ground it 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 (same approach as
   #report-table / #telematics-table). */
#warranty-table { table-layout: fixed; }
/* Widths sum to 100% and are tuned against the content width (main caps at
   1280px, ~1232px across the 14 columns). Defects/Mods/Live only show a small
   count, badge or "—", so they stay narrow. Cover is narrow BY DESIGN: a badge
   plus one count ("Out · 2"), with the full breakdown in its title; it needs 12%
   because the badge is an inline chip that cannot ellipsize gracefully. Terms
   needs 7%: a sortable th reserves 22px on the right for the ▲/▼ indicator.
   Both come out of VIN, which is always ellipsised (the whole VIN is in the
   cell's title and the CSV export). Fleet/Job/Reg are short. */
#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 from 1500px, where the stepped max-width widens
   the table (~1672px at 1800px+). Every header fits except "Defects": 5% of
   1672px is ~84px, minus 22px for the sort indicator, leaves ~48px against the
   ~58px the word needs. The point comes from VIN, which always ellipsises.
   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. It must stay on ONE line (a wrapping cell would set the whole
   row's height), so it ellipsizes with the full breakdown in the title. The
   badge is inline-block, so the standard 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; only the list rows are clickable. */
#wr-table tbody tr { cursor: default; }

/* Fleet coverage matrix. An EXPOSURE report, so the eye must land on the gaps: a
   zero 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. Tint, not
   background fill, so 1 and 400 stay distinguishable. */
#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, pinned on the ID. #wr-table's only structural reason to
   wear `warranty-table` is these cell rules (its columns are dynamic, so it
   skips the list table's fixed widths); this keeps its numeric columns
   right-aligned if the class is ever dropped. 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). The note is a warning, not an
   error. */
.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, with a small
   status chip per line. */
.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; the column is never that wide).
   Headers wrap instead: a clipped column name cannot be recovered by hovering. */
.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 the widths onto the wrong columns.
   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 the uppercase/sticky header styling does
   not apply; it takes the header background and keeps the rule under itself that
   `tr:last-child td` would 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 all share one fixed height (--vh-card-h, tall
   enough for the Live-telemetry rows). Row 1 = Bus details, Position (2 cols),
   Live telemetry; row 2+ = Warranty, Active Campaigns, Previous Campaigns (each
   scrolls if its list is long).
   The height lives on the CARD, not the row track (`grid-auto-rows` stays
   `auto`): a fixed track would stretch a sparse full-width card (e.g. the VOR
   card with one fault) into a dead band. Full-width cards opt out with
   `height: auto` (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 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. scrollbar-gutter reserves space for the scrollbar to the right of the
   content. */
.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: otherwise the contentless SoC
   bar (the only zero-text child) is flex-shrunk to nothing in the fixed-height
   column. */
#vh-live > * { flex-shrink: 0; }
/* Keep each telemetry value on one line in this narrow column; a tight row
   ellipsises the short label rather than wrapping the value 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 matches the Bus details
   card. `height: auto` for the same reason as .vh-card-vor; it still stretches
   when sharing a row with a standard-height card, so the row edge stays even. */
.vh-card-software { height: auto; }
/* Versions are identifiers: tabular figures so 0.8.2 and 0.1.5 line up, 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 read together: pick a journey on the left, see the line on
   the right. Full row, because a route in a quarter-width box is a squiggle.
   `height: auto` with a max, like .vh-card-vor: a day with two journeys is not
   stretched and a day with twenty does 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; }
/* Caveats (a partial day, a day before capture) sit ABOVE the list, in the
   reading path, where a scrolled list cannot 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 the table's natural width would widen the card instead of scrolling
   inside its 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 would force this table 720px wide inside a
   ~470px column. 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 scrollbar 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 with this top one would double-rule 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 the selection tint
   only lands on Enter, not 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); }
/* Room for the 3px selection bar (the cells carry no left padding, so it would
   cover the first digit). Applied to every row and the header so the column does
   not shift when a row is picked. */
.vhj-table th:first-child, .vhj-table td:first-child { padding-left: 10px; }
.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, since an
   empty full-height table beside an empty map reads as a broken card. */
.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. */
.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; }
}
/* All four columns fit the card on a phone (nothing is dropped); the header
   controls wrap. */
@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 (no hardcoded hex), so it reads in BOTH
   themes. The box tints reuse the values of .warranty-badge-*, so 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 in
   the same card. Content-sized like the VOR card (the SVG is capped at 900px
   wide) rather than spanning fixed 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 when the vehicle has no
   matched Salesforce Asset). Reads as a note, not data: muted text on the inset
   --panel-2 ground (--panel is the .vh-card background, so it would have no
   contrast), a warning rule down the leading edge, and no badge geometry. 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 inherits the page font family AND colour (no `font:` shorthand, which
   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 matches .warranty-badge-* but is mixed with the card
   background rather than translucent: a box may sit ON the bus outline (the
   rear-axle box does), and a see-through fill would let the geometry 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. Must not be confusable with
   .wmap-unknown (a term IS recorded but could not be parsed or dated). Both use
   --muted (--line-strong is 0.22-alpha and nearly invisible as a 1.5px dashed
   stroke or the "+" fill), so they differ by FILL: unknown keeps a tint; this is
   drawn on the bare card ground with the dashed outline .wmap-dash 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); opacity: 0.55; }

/* Selection: the chosen box stays lit, the rest dim (the odos .has-sel pattern). */
.wmap-box { cursor: pointer; transition: opacity 200ms ease; }
.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), so it
   opts out of --control-h the same way. */
.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 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. */
  .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, 1px --line border,
   --radius, --shadow-card and the --line-strong hover. Every tile in both views
   is emitted by faultStatTileHtml() in app.js, so they stay in step. */
.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, like .wmap-* above, so the ground
   tint and border cannot disagree. No left bar: nothing else in the app signals
   severity with an edge rule; the warranty map and VOR surfaces use a low tint of
   a token plus a stronger edge of the same token.
   BOTH mixes take --panel, not --line or transparent: --line is rgba(…, 0.14)
   and color-mix interpolates alpha too, so mixing an opaque status into it
   washes the hue out and the three states would look alike. Against the opaque
   --panel the percentage means what it says, 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`, so a screen reader announces label first.
   faultStatTileHtml() emits label, num, sub in that order. */
.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 stat scale).
   --text, NOT --fault-status: a token on its own 10% tint fails contrast in the
   light theme (--warn #F98513 measures 2.3:1, --good #0AC135 2.2:1, below even
   3:1), and is-good is the default state. The ground tint and --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 clip + ellipsize rather than
   overflow (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 ──
   Percentage widths (as #report-table uses) so all ten columns stay inside
   .table-wrap on a laptop; long values (operator, type, geofence) ellipsize and
   keep the full text in the title attribute. */
/* Keyed on the CLASS each column carries, not its position: nth-child widths
   would drift from the `.col-*` classes if a column is inserted or reordered,
   and the pixel `.col-fleet`/`.col-reg` rules above would be 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 "…12 d…" loses the number). It wraps, and the cell carries a title. */
#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 (no per-bus mileage page), so they do 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 stretching the
   status line. The day/range/reporting selects are plain `.report-control`
   children. */
.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; }

/* 7-day fault activity: seven bars, today last. Empty days keep a hairline so
   the strip reads as a week. */
.fx-spark { display: flex; align-items: flex-end; gap: 2px; height: 22px; width: 100%; max-width: 84px; }
.fx-spark-bar { flex: 1; min-height: 1px; border-radius: 1px; background: var(--warn); }
.fx-spark-bar.is-empty { height: 1px !important; background: var(--line-strong); }

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

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

/* ──────────── Faults triage screen (/reports/faults) ────────────
   A filter rail plus a single results table, drawn from existing tokens and
   control recipes (.group-multi pickers, .seg/.seg-opt toggles, .fault-stat
   tiles, .fault-table grid), so it inherits a fleet theme like the rest of the
   app. New shapes: the rail, the header pill row and the status/severity badges. */
/* Five tiles across, not .fault-summary's flex wrap: at this width the flex row
   lands four-plus-one and the orphan stretches to the full measure. */
.faults-view .fault-summary {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  gap: 12px;
}
.faults-layout {
  display: grid;
  grid-template-columns: 268px minmax(0, 1fr);
  gap: 22px;
  align-items: start;
}
/* Below ~1500px the page shares its width with the nav rail, and the rail's
   268px comes out of the Active faults column (which carries ~30-character DTC
   names), so the rail narrows by 28px first. */
@media (max-width: 1500px) {
  .faults-layout { grid-template-columns: 240px minmax(0, 1fr); gap: 18px; }
}
/* The rail is a column of its own, not a card per group; one hairline between
   groups is enough. */
/* THE RAIL IS NOT A SCROLL CONTAINER, and must not become one. A sticky rail with
   `max-height` + `overflow-y: auto` breaks the pickers inside it:
     1. `overflow-y: auto` makes the box scrollable on BOTH axes, so it clips its
        absolutely-positioned descendants horizontally (the Bus type panel is
        wider than the rail and loses its option counts).
     2. Opening a picker focuses its search box, and focus scrolls the scrollable
        ancestor into view, so the rail scrolls sideways and takes the labels
        with it (the same trap positionGroupMultiPanel() documents).
   The rail scrolls with the page, like the `.report-controls` bar on other
   reports; the chip bar above the table echoes the active filters. Portalling
   the panels to `position: fixed` would need scroll repositioning, close-on-scroll
   and `preventScroll` across ten pickers. */
.faults-side {
  display: flex;
  flex-direction: column;
  gap: 18px;
}
.faults-side-group {
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding-bottom: 16px;
  border-bottom: 1px solid var(--line);
}
.faults-side-group:last-of-type { border-bottom: 0; padding-bottom: 0; }
.faults-side-title {
  margin: 0 0 2px;
  font-size: 10.5px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.12em;
  color: var(--muted);
}
.faults-search {
  width: 100%;
  background: var(--panel);
  color: var(--text);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  min-height: var(--control-h);
  padding: 6px 10px;
  font: inherit;
  font-size: 13px;
}
.faults-search::placeholder { color: var(--muted); }
/* THE input focus ring, one rule for every field that wears it. Placed after each
   field's own base and :hover rules so it wins on equal specificity. .card-fleet
   and .user-menu-select use :focus-visible so a mouse click does not ring them. */
.subs-inline-name:focus,
.sr-edit input:focus,
.sr-edit select:focus,
#bus-search:focus,
.card-fleet:focus-visible,
.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,
.app-modal-input:focus,
.app-modal-textarea:focus,
#users-view .users-add input:focus,
#users-view .users-add select:focus,
#users-view .users-edit-name:focus,
#users-view .users-edit-role:focus,
.user-menu-select:focus-visible,
.faults-search:focus {
  outline: none;
  border-color: var(--primary);
  box-shadow: 0 0 0 3px var(--primary-soft);
}
/* The rail is narrower than the 220px .group-multi floor. Otherwise these are
   the dashboard's pickers unchanged, with the field name in a label above, as
   every other control on this rail and the report pages is captioned. */
.faults-side .faults-multi { min-width: 0; }
.faults-side .faults-multi .group-multi-trigger { font-size: 12.5px; }
/* May be wider than the 240px rail: the bus-type catalogue carries names like
   "StreetDeck Ultroliner 4 Cylinder Low Bridge", and the shared
   `.group-multi-panel` flips left when it would overrun the viewport
   (positionGroupMultiPanel). */
.faults-side .faults-multi .group-multi-panel { max-width: min(380px, calc(100vw - 24px)); }

.faults-seg { flex-wrap: wrap; }
/* .seg sets an author-origin `display: inline-flex`, which outranks the UA
   `[hidden] { display: none }` (same trap as .btn-primary above).
   renderFaultsReport hides the powertrain tabs when the fleet in view has one
   drivetrain. */
.faults-seg[hidden] { display: none; }
/* Multi-select toggles, unlike the miles/km radio group, so they carry
   aria-pressed rather than aria-checked. Same painted states. */
.faults-seg .seg-opt[aria-pressed="true"] {
  background: var(--primary);
  color: var(--primary-ink);
  font-weight: 600;
}

.faults-checks { display: flex; flex-direction: column; gap: 2px; }
.faults-check {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 5px 6px;
  border-radius: var(--radius-sm);
  font-size: 12.5px;
  cursor: pointer;
}
.faults-check:hover { background: var(--primary-soft); }
.faults-check input { accent-color: var(--primary); margin: 0; flex: 0 0 auto; }
.faults-check-label {
  flex: 1 1 auto;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.faults-check-count {
  flex: 0 0 auto;
  color: var(--muted);
  font-size: 11.5px;
  font-variant-numeric: tabular-nums;
}
/* A control with no data source stays visible and says why; its absence would
   read as a missing feature. */
.faults-check.is-unavailable { cursor: not-allowed; }
.faults-check.is-unavailable:hover { background: none; }
.faults-check.is-unavailable .faults-check-label,
.faults-check.is-unavailable .faults-check-count { color: var(--muted); opacity: 0.65; }

.faults-side-field { display: flex; flex-direction: column; gap: 4px; }
.faults-side-field-label { font-size: 12px; color: var(--muted); }
/* No <select> on this screen. Fault age, Warranty and Sorted by are .group-multi
   like the fleet pickers, because a native select draws its OPEN list with the
   operating system and ignores the theme. */
.group-multi-trigger:disabled {
  opacity: 0.55;
  cursor: not-allowed;
  background: var(--panel);
}
.group-multi-trigger:disabled:hover { border-color: var(--line); }
.faults-side-note { margin: 0; font-size: 11.5px; color: var(--muted); }
.faults-side-actions {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
  padding-top: 4px;
}
.faults-side-count { font-size: 12px; font-variant-numeric: tabular-nums; }

.faults-head { margin-bottom: 16px; }
.faults-head-row {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 10px;
  margin-bottom: 6px;
}
.faults-head-row .report-title { margin: 0; }
/* The header stamps and actions share one shape (a low-contrast pill) so a
   read-only status and a pressable button sit on one line without one shouting
   over the other. The button variant adds the pointer, hover and pressed state. */
.faults-pill {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  min-height: 28px;
  padding: 3px 12px;
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-pill);
  background: transparent;
  color: var(--muted);
  font: inherit;
  font-size: 12px;
  white-space: nowrap;
}
.faults-pill:empty { display: none; }
.faults-pill-btn { cursor: pointer; color: var(--text); transition: background 120ms ease, border-color 120ms ease; }
.faults-pill-btn:hover { background: var(--primary-soft); border-color: var(--primary); }
.faults-pill-btn[aria-pressed="true"] {
  background: var(--primary);
  border-color: var(--primary);
  color: var(--primary-ink);
  font-weight: 600;
}

/* Summary tiles are buttons: each names a slice of the queue, so the tile and
   the filter that produces it are the same control. Everything .fault-stat paints
   is inherited; this only undoes the UA button defaults. */
button.fault-stat {
  font: inherit;
  text-align: left;
  cursor: pointer;
  color: inherit;
}
button.fault-stat[aria-pressed="true"] { border-color: var(--fault-status, var(--primary)); }

.faults-chipbar {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 10px;
  margin-bottom: 14px;
}
.faults-chipset { display: flex; flex-wrap: wrap; gap: 8px; flex: 1 1 auto; min-width: 0; }
.faults-chipbar-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
/* An applied filter, echoed back with its own dismiss. The tinted ground is the
   .fault-chip recipe at --primary, so the two read as one family. */
.faults-chip {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 4px 6px 4px 12px;
  border-radius: var(--radius-pill);
  font-size: 12px;
  background: color-mix(in srgb, var(--primary) 14%, transparent);
  border: 1px solid color-mix(in srgb, var(--primary) 38%, transparent);
  color: var(--text);
}
.faults-chip-name { color: var(--muted); }
.faults-chip-x {
  border: 0;
  background: none;
  color: var(--muted);
  font: inherit;
  font-size: 13px;
  line-height: 1;
  padding: 2px 6px;
  border-radius: var(--radius-pill);
  cursor: pointer;
}
.faults-chip-x:hover { background: var(--primary-soft); color: var(--text); }
.faults-chip-x:focus-visible { outline: 2px solid var(--primary); outline-offset: 1px; }
.faults-sort-label { font-size: 12px; color: var(--muted); }


/* ── sortable headers ──
   The shared `table.sortable` recipe puts the ▲/▼ in a 22px gutter on the RIGHT.
   On the two right-aligned numeric columns that gutter sits between label and
   edge, pushing "VOR OPEN" off the figures it heads, and at laptop width (~60px
   columns) the label wraps for want of 22px. The gutter flips to the left there. */
/* A 14px gutter, not the shared 22px (the same trim .vor-table takes). The
   indicator glyph is 9px; 22px would truncate "FLEET / REG" and "VOR OPEN" at
   1280px where eight columns share the measure. */
.faults-table.sortable th[data-sort-key] { padding-right: 14px; }
.faults-table.sortable th .sort-ind { right: 3px; }
/* The label WRAPS rather than ellipsing: a two-word header in an 8% column takes
   two lines (the header row grows from 34px to 48px). */
.faults-table.sortable th[data-sort-key] .th-label {
  display: block;
  white-space: normal;
  overflow: visible;
  text-overflow: clip;
}
/* The two right-aligned numeric columns put the indicator on the LEFT so the
   header stays against the figures it heads. */
.faults-table.sortable th.fxc-vor,
.faults-table.sortable th.fxc-seen {
  padding-right: 10px;
  padding-left: 14px;
}
.faults-table.sortable th.fxc-vor .sort-ind,
.faults-table.sortable th.fxc-seen .sort-ind { right: auto; left: 3px; }
/* The header row is sticky (the app-wide `th` rule), so a hovered header must
   not pick up the body's row-hover tint. */
.faults-table.sortable thead th[data-sort-key]:hover { background: var(--bg-deep); }

/* Percentage widths, like #mileage-table: pixel columns push the right-hand
   ones off .table-wrap on a laptop once the rail has taken 268px. */
.faults-table { table-layout: fixed; }
/* The widths sum to exactly 100 INCLUDING the chevron: the table is
   `table-layout: fixed` at `width: 100%`, so an overrun clips the Last seen
   header off the edge of .table-wrap. Any change must rebalance the rest.
   Movement keeps 13% ("Movement unknown" clips at 12%); Active faults keeps
   enough to show a fault name. */
.faults-table .fxc-id     { width: 11%; }
.faults-table .fxc-org    { width: 13%; }
.faults-table .fxc-status { width: 14%; }
.faults-table .fxc-move   { width: 13%; }
.faults-table .fxc-spark  { width:  6%; }
/* The widest column by a distance: a DTC name is ~30 characters and truncating
   it makes the row's subject unreadable. */
.faults-table .fxc-faults { width: 23%; }
.faults-table .fxc-vor    { width:  8%; }
.faults-table .fxc-seen   { width:  8%; }
/* .fxc-go (4%) is set with its alignment, below. */
.faults-table td { vertical-align: middle; }
.faults-table td.fxc-id,
.faults-table td.fxc-org,
.faults-table td.fxc-status,
.faults-table td.fxc-move {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* Two lines per identity cell: the scan target on top, the disambiguator
   underneath. Each line clips on ITS OWN box: the td's `text-overflow` never
   fires because the overflowing element is the block-level span inside it. */
.fx-primary,
.fx-secondary {
  display: block;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.fx-primary { font-weight: 600; color: var(--text); }
.fx-secondary { font-size: 11.5px; color: var(--muted); }
/* `nowrap` on the CELLS only. The class sits on the <th> too, and an 8% column
   cannot hold "VOR OPEN" on one line at laptop width; the header wraps, the
   figures never do. */
.faults-table th.fxc-vor,
.faults-table th.fxc-seen { text-align: right; }
.faults-table td.fxc-vor,
.faults-table td.fxc-seen {
  text-align: right;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
.fx-vor-open { color: var(--error); font-weight: 600; }
.fx-move-live { color: var(--good); font-weight: 600; }
/* ── the row expands; the chevron navigates ──
   Two targets in one row. The row takes the pointer only when there is
   something behind it; the chevron has its own hover and focus ring so it never
   reads as part of the row. */
.faults-table .fxc-go { width: 4%; text-align: right; padding-left: 0; padding-right: 4px; }
.fx-open {
  border: 0;
  background: none;
  color: var(--muted);
  font: inherit;
  font-size: 16px;
  line-height: 1;
  padding: 4px 6px;
  border-radius: var(--radius-sm);
  cursor: pointer;
  transition: background 120ms ease, color 120ms ease;
}
.fx-open:hover { background: var(--primary-soft); color: var(--text); }
.fx-open:focus-visible { outline: 2px solid var(--primary); outline-offset: -1px; color: var(--text); }

/* Every row opens, so every row is a control. */
.faults-table tbody tr.fx-row,
.faults-table tbody tr.fx-row td { cursor: pointer; }
.faults-table tbody tr.fx-row:focus-visible {
  outline: 2px solid var(--primary);
  outline-offset: -2px;
}
/* An open row and its detail read as one block: the row loses its bottom rule
   and both sit on the same faint ground. */
.faults-table tbody tr.fx-row.is-open > td { border-bottom: 0; background: var(--primary-soft); }
.faults-table tbody tr.fx-row.is-open .fault-chip-more { color: var(--text); }

.faults-table td.fx-detail-cell { padding: 0; background: var(--primary-soft); }
.fx-detail {
  display: flex;
  flex-direction: column;
  gap: 10px;
  /* Indented to the Active faults column so the chips line up under it. A plain
     inset below the breakpoint where the columns move. */
  padding: 2px 14px 14px 12%;
}
@media (max-width: 1400px) { .fx-detail { padding-left: 13%; } }
@media (max-width: 1080px) { .fx-detail { padding-left: 14px; } }
.fx-detail-group { display: flex; flex-direction: column; gap: 5px; }
/* One fault per line, each with the signal it arrived on and when it was last
   seen. */
.fx-detail-items { display: flex; flex-direction: column; gap: 5px; align-items: flex-start; }
.fx-detail-item { display: flex; align-items: baseline; flex-wrap: wrap; gap: 4px 10px; max-width: 100%; }
.fx-detail-meta { font-size: 11.5px; color: var(--muted); }
/* Warranty cover on the faulted component. One tone per state, the same
   vocabulary as the fault chips: an expired term reads in --error; "in cover" is
   the quiet answer and stays muted. */
.fx-cover {
  font-size: 11.5px;
  font-weight: 600;
  white-space: nowrap;
  color: var(--fx-cover-tone, var(--muted));
}
.fx-cover-out          { --fx-cover-tone: var(--error); }
.fx-cover-expiring     { --fx-cover-tone: var(--warn); }
.fx-cover-not-recorded { --fx-cover-tone: var(--muted); font-weight: 500; font-style: italic; }
.fx-cover-unknown      { --fx-cover-tone: var(--muted); font-weight: 500; font-style: italic; }
.fx-cover-in           { --fx-cover-tone: var(--muted); font-weight: 500; }
.fx-detail-caveat { margin-top: 2px; font-style: italic; }
.fx-detail-label {
  font-size: 10px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.12em;
  color: var(--muted);
}
/* These WRAP and are shown WHOLE, where the row's own cell clips them to one
   line. `flex: 0 0 auto` is load-bearing: `.faults-table .fault-chip` sets
   `min-width: 0`, and a shrinkable flex item with the base `overflow: hidden`
   would still ellipsise; `max-width` alone does not stop it. Three classes deep
   so it beats `.faults-table .fault-chip` on specificity, not source order. */
/* Three classes deep for the same reason as the chip rule below:
   `.faults-table .fault-chips` sets `flex-wrap: nowrap` at equal specificity
   and later in the file, so a two-class `.fx-detail .fault-chips` would lose the
   tie and run its chips off the table's edge. */
.faults-table .fx-detail .fault-chips { flex-wrap: wrap; overflow: visible; }
/* A chip inside a detail item is a flex child of .fx-detail-item, not of a
   .fault-chips row, so it needs the same no-shrink treatment. */
.faults-table .fx-detail-item > .fault-chip { flex: 0 0 auto; }
.faults-table .fx-detail .fault-chip {
  flex: 0 0 auto;
  min-width: 0;
  max-width: 100%;
  overflow: visible;
  text-overflow: clip;
  white-space: normal;
}
/* Not a clickable row, so it must not offer the row hover. */
.faults-table tbody tr.fx-detail-row:hover td { background: var(--primary-soft); }
/* The app-wide `tbody tr:hover td` sets cursor:pointer; this row is not a
   control, so the override must reach the td too. */
.faults-table tbody tr.fx-detail-row td { cursor: default; }
.faults-th-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* Status badge. One recipe, one token per state (the .fault-chip pattern). */
.fx-status {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 2px 9px;
  border-radius: var(--radius-pill);
  font-size: 11.5px;
  font-weight: 600;
  background: color-mix(in srgb, var(--fx-tone) 16%, transparent);
  border: 1px solid color-mix(in srgb, var(--fx-tone) 38%, transparent);
  color: var(--fx-tone);
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.fx-status::before {
  content: "";
  flex: 0 0 auto;
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: currentColor;
}
/* The narrow-width copy of the Movement cell, folded under the badge; hidden
   where Movement has its own column. */
.fx-status-move { display: none; font-size: 11.5px; margin-top: 3px; }
.fx-status-vor        { --fx-tone: var(--error); }
.fx-status-service    { --fx-tone: var(--good); }
.fx-status-depot      { --fx-tone: var(--info); }
.fx-status-stationary { --fx-tone: var(--warn); }
.fx-status-dark       { --fx-tone: var(--muted); }

/* One row of chips per bus, clipped to a single line so a bus with nine codes
   does not make its row nine times as tall. The overflow count carries the rest
   and the cell's title has the full list. */
.faults-table .fault-chips { flex-wrap: nowrap; overflow: hidden; align-items: center; }
.faults-table .fault-chip { min-width: 0; }
/* The roll-up must never wrap: "+1" / "more" on two lines doubles the row height
   for the least important text in it. */
.faults-table .fault-chip-more { white-space: nowrap; flex: 0 0 auto; }

.faults-empty {
  padding: 28px 12px;
  text-align: center;
  color: var(--muted);
  font-size: 13px;
}

/* Below ~1400px the page shares its width with the nav rail AND the filter rail,
   and the eight-column table would clip the status badge and DTC name. Movement
   gives up its column and becomes the badge's second line (Status and Movement
   are one fact about the bus), and its 11% goes to Active faults. `display: none`
   on the cells is not enough under `table-layout: fixed`: the remaining widths
   are restated to sum to 100. */
@media (max-width: 1400px) {
  .faults-table .fxc-move { display: none; }
  .faults-table .fx-status-move { display: block; }
  .faults-table td.fxc-status { white-space: normal; }
  .faults-table .fxc-id     { width: 12%; }
  .faults-table .fxc-org    { width: 14%; }
  .faults-table .fxc-status { width: 20%; }
  .faults-table .fxc-spark  { width:  6%; }
  .faults-table .fxc-faults { width: 29%; }
  .faults-table .fxc-vor    { width:  8%; }
  .faults-table .fxc-seen   { width:  7%; }
  .faults-table .fxc-go     { width:  4%; }
}

/* Below ~1080px the rail and the table cannot both have a usable measure, so the
   rail goes full width above the results and un-sticks. */
@media (max-width: 1080px) {
  .faults-layout { grid-template-columns: minmax(0, 1fr); }
  /* Multi-column, not a grid: a grid sizes each ROW to its tallest item and would
     leave a hole under the one-field Search group. Columns pack groups by height,
     and `break-inside: avoid` keeps a group whole. */
  .faults-side {
    display: block;
    columns: 240px;
    column-gap: 22px;
  }
  .faults-side-group,
  .faults-side-actions {
    border-bottom: 0;
    padding-bottom: 0;
    break-inside: avoid;
    margin-bottom: 18px;
  }
  /* Restores the flex column each group has apart from the `display: block` above:
     the groups are stacks of controls laid out in columns. */
  .faults-side-group { display: flex; }
}


/* ──────────── Theme manager (/theme) ────────────
   Two columns (slot editor left, two live previews right), one column under
   1000px. Drawn with the existing tokens and .report-* / .btn-* patterns, so it
   inherits the viewer's theme; that matters because the thing being edited is a
   DIFFERENT palette and the two must not be confusable, hence the boxed, labelled
   previews. */
.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 columns line up. */
.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 other controls; the padding/border
   reset stops the browser's own chrome rendering a grey button with a stamp in
   it. */
.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(); that is the preview mechanism. 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. The `[hidden]` guard is REQUIRED: an author `display`
   outranks the UA `[hidden] { display: none }` and this element is toggled with
   el.hidden (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 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; }
/* Previews across the full width, controls beneath, NOT side by side: the two
   tiles are compared with each other, and a header bar squeezed into half a
   column crops the fleet mark. */
.theme-logo-row { display: flex; flex-direction: column; align-items: stretch; gap: 14px; }
/* THE LOGO PREVIEWS ARE MINIATURE HEADERS, one per mode. The mark is drawn at
   28px on --panel beside our own lockup: the size it is rendered at, the surface
   it lands on, and the mark it must sit beside. Both tiles have their tokens set
   inline by renderThemeEditor(), so --panel and --line resolve to the EDITED
   palette, not the running one. */
.theme-logo-previews {
  display: grid;
  /* `min(260px, 100%)`, not a bare 260px: auto-fit still lays ONE track at the
     stated minimum, so on a 320px phone a bare floor plus the report and card
     padding causes a sideways scroll. */
  grid-template-columns: repeat(auto-fit, minmax(min(260px, 100%), 1fr));
  gap: 12px;
}
.theme-logo-preview {
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  overflow: hidden;
}
.theme-logo-preview-head {
  display: block;
  padding: 4px 10px;
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--muted);
  background: var(--bg);
  border-bottom: 1px solid var(--line);
}
/* The header's own geometry: .brand-cobrand's 12px gap and 28px marks, and the
   1px/28px hairline divider between the two brands. */
.theme-logo-bar {
  display: flex;
  align-items: center;
  gap: 12px;
  min-height: 52px;
  padding: 12px 14px;
  background: var(--panel);
}
.theme-logo-uptime { height: 28px; width: auto; flex: 0 0 auto; }
.theme-logo-divider {
  width: 1px;
  height: 28px;
  flex: 0 0 auto;
  background: var(--line);
}
.theme-logo-img { height: 28px; width: auto; max-width: 140px; object-fit: contain; display: block; }
.theme-logo-preview [hidden] { display: none; }
.theme-logo-empty { font-size: 12px; }
.theme-logo-controls { display: flex; flex-direction: column; gap: 8px; }
.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.
   One set of geometry tokens drives the launcher, its hit pad AND the widget
   panel's offset and height cap (--heyhy-panel-* below), so they cannot drift.
   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, keeping the hit-test boundary
     away from where a hand moves: a boundary on the visible edge makes the cursor
     flip hand/arrow when sweeping across the icon. 14px keeps crossing the visible
     circle inside one cursor. */
  --ai-launcher-slop: 14px;
}

/* Mid-session re-auth prompt (see #session-expired-overlay in index.html). Sits
   above every panel so an expired session cannot be worked around. */
.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, and far below #session-expired-overlay (2000). That also puts it
   below #map.is-expanded (3000), so it is hidden while the map is expanded, as
   native :fullscreen does anyway. */
.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, which
     would replace the pointer cursor mid-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 clickable
   area is a circle inside a square footprint and the four corner slivers belong
   to whatever is underneath (map `grab`, dashboard `auto`), flipping the cursor.
   This square pad restores a rectangular hit area behind the round visual so the
   cursor is `pointer` across the footprint. It paints nothing; hitting a
   pseudo-element counts as hitting its originating element, so hover, click and
   focus stay with the button. `-1px` covers the border too. The ~8px corners
   activate the chat though they look like map, so the pad stays within the
   footprint. */
.ai-launcher::before {
  content: '';
  position: absolute;
  /* Hit slop on the two sides you approach from, so sweeping across the visible
     circle stays inside one cursor (the boundary otherwise sits on the visible
     edge, where hand movement crosses it back and forth). */
  inset: calc(-1 * var(--ai-launcher-slop));
  /* Reach the viewport's right and bottom edges. The launcher is inset 20px, which
     leaves two thin `cursor: auto` strips between it and the window edge; moving
     towards the corner would flip the cursor, repeatedly at a half-pixel boundary
     at DPR 2. Left and top stay tight: extending there would take clicks 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 by colour and shadow only, which cannot affect hit-testing.
   A lift (`translateY`) pulls the button from under a pointer in its bottom
   pixels, so hover ends, restarts, and the cursor oscillates between the hand and
   whatever is underneath; any interactive geometry change invites that bug at
   some zoom, pixel ratio or timing. So: no transform. For motion, animate
   something outside 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 where it is (it is the control that closes the
   window). It 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. Otherwise the <img> is what the pointer
     lands on, and dragging across the launcher starts a native image drag that
     swaps the cursor 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, so the
   state changes without animation under this preference. */
@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. It sits in the launcher's corner just above
   it, using the same geometry tokens so it cannot overlap the button. Above the
   launcher's z-index and below the side-menu overlay. */
.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; muted, because it qualifies the number beside 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 is a warning condition, so it reads in --warn (orange) throughout,
   distinct from the green live/healthy language and from --error, which the app
   reserves for failed requests. */

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

/* Two stats side by side: reported, and not moved 24h. Equal columns, each
   number centred in its column, so neither reads as primary. The rest of the
   card stays left-aligned.
   EQUAL-SIZED BOXES, held by three independent things so a wording change cannot
   unsquare the card:
     - `align-items: stretch` (the grid default, left implicit) equalises the
       heights.
     - the button is a flex COLUMN with `justify-content: flex-start`, keeping
       the numbers on one line under stretch (a `display: block` button taller
       than its content has it VERTICALLY CENTRED by the UA).
     - the labels are short enough not to wrap. */
.card-vor-stats {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 4px 10px;
  /* Left-aligned like every other topcard. The stats are buttons with 8px of
     padding for their hover and pressed fill, so the grid is pulled out by the
     same 8px and the numbers line up with the card title. */
  margin: 0 -8px;
  text-align: left;
}
/* Each stat is a button that filters the map. The "on" ramp is --warn, not
   --primary, because off-road is a warning condition (as the VOR pins and report
   use). Transparent border at rest so pressing one does not shift the layout. */
.card-vor-stat {
  /* Flex column, not block: a block button centres its content vertically when the
     grid stretches it, dropping one number below the other. */
  display: flex;
  flex-direction: column;
  justify-content: flex-start;
  align-items: flex-start;
  width: 100%;
  padding: 6px 8px;
  background: none;
  border: 1px solid transparent;
  border-radius: var(--radius-sm);
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: pointer;
  transition: background 120ms ease, border-color 120ms ease;
}
/* .big / .small are written for block context and are spans inside the button
   (a button takes phrasing content only). As flex items they are already
   blockified; this keeps them correct if the button 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, so an active filter is never
   stranded. */
.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; }
}
/* The open-fault number takes its own full-width row, number and caption on
   one line, so the two captions above keep one line each. */
#vor-filter-open {
  grid-column: 1 / -1;
  flex-direction: row;
  align-items: baseline;
  gap: 6px;
  padding: 4px 8px;
}
#vor-filter-open .big { font-size: 15px; }
#vor-filter-open .small { margin-top: 0; }
#card-vor-scope { text-align: left; }
/* The report link sits under the numbers, left, as "View faults" does on the
   Daily faults card. */
.card-vor-controls {
  display: flex;
  align-items: center;
  margin-top: 4px;
}
.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;
}

/* 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: lines are separated by
   <br>, and a block-level box between two 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). */
.pin-tip-muted { opacity: 0.65; }
/* Same fills as the map pins, off the same tokens (--pin-* in :root), so a
   tooltip dot and its pin cannot 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: wrapped, not truncated, since 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 and 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. Sizes to its content, bounded at 1.5 standard cards so a long fault list
   stays on screen; .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. The track is bounded: 560px is the narrowest a
   fault reads well at and 760px the widest before the free text is too long to
   scan, so wide screens get two tiles and narrow ones 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; with two columns a between-items border-top would
   read as a stray rule. */
.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. */
.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
   values ~100px to wrap in. Label stacks over value instead. */
@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. */
  .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. */
/* Collapsible "about this report" block under a report title. A real <details>
   /<summary>, so the disclosure triangle, keyboard behaviour and screen-reader
   announcement are the browser's. Closed on load; the snapshot's as-at date is
   on the summary line. */
.report-about { margin-top: 6px; }
.report-about > summary {
  cursor: pointer;
  color: var(--muted);
  font-size: 12px;
  /* Shrink-wrap the click target: full-width, a click in the empty space beside
     the text would toggle the section. */
  width: fit-content;
}
.report-about > summary:hover { color: var(--text); }
.report-about[open] > summary { margin-bottom: 8px; }
/* Spacing only; the paragraphs keep .report-sub's own colour and size. */
.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 even when the
   snapshot is stale, because the words already say so and a whole line of --warn
   reads as a fault. The 600 weight is the only emphasis. --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 link to the full vehicle page where a VIN was resolved; unmatched
   rows are deliberately not clickable. */
#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 would grow an
   auto-layout table of this many columns past the 1280px page. Fixed layout +
   the colgroup widths renderVorReport() emits makes each column obey its share
   so the ellipsis engages; every truncated cell carries a title tooltip. */
.vor-table {
  table-layout: fixed;
  min-width: 0;
  width: 100%;
}
/* `min-width: 0` is a DESKTOP concession (fourteen columns fit a 1280px page).
   On a 360px phone it would make every column ~19px wide, under the 24px WCAG
   floor and unreadable. Below the tablet breakpoint it goes back to the 720px
   every other data table has and scrolls inside .table-wrap. */
@media (max-width: 900px) {
  .vor-table { min-width: 720px; }
}
.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 width 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 ▲/▼, enough to ellipsise six of the twelve labels,
   and a clipped header leaves the column unidentifiable. So: a tighter indicator
   gutter and half the usual header letter-spacing (~12px back per header). 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 matches every other sortable table. */
.vor-table.sortable th .sort-ind { right: 3px; }
/* HEADERS WRAP, CELLS DO NOT. With fourteen columns and labels as long as
   "Miles since raised (OBD)" (210px on one line), a nowrap header is
   unaffordable and clipping the label is the one thing this header row must not
   do. Cells keep nowrap + ellipsis: a wrapping CELL would set its whole row's
   height from one long fault description. */
.vor-table th { letter-spacing: 0.06em; 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
   until then.

   The widget ships no palette: every colour, radius and font is read as
   var(--heyhy-…, neutral-fallback). Mapping each token to one of this app's own
   makes it look like this app and follows html[data-theme] with no second theme
   mechanism.

   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 asserts they cover every token the widget
   reads. An unmapped token silently falls back to the package's neutral
   placeholder, in exactly one of the two themes.

   Scoped to .heyhy-root rather than :root: these names mean nothing outside the
   widget. The last two are the panel geometry, written in terms of the launcher
   variables so the docked window clears #ai-launcher by the launcher's own gap.

   NO COMMENTS INSIDE THE BRACES. The parity test splits the block on `;`, so a
   comment in front of a declaration hides it 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)));
  /* dvh, as for .bus-detail: on a phone the vh form puts the composer below the
     fold, behind the address bar. No `vh` fallback line for THIS token:
     heyhy-widget-config.js mirrors every --heyhy-* token and the widget sets them
     inline at mount, so an inline dvh value would win over a CSS fallback anyway. */
  --heyhy-panel-height: min(600px, calc(100dvh - var(--ai-launcher-size) - var(--ai-launcher-inset) * 2 - var(--ai-launcher-gap) - 72px));
}

/* The widget renders its own launcher and has no flag to suppress it. This app
   has #ai-launcher (Hy! mark, hit slop, scope gating), and two bubbles in one
   corner is not acceptable (APP_DESIGN.md 12.5), so the widget's launcher is
   hidden and #ai-launcher drives widget.open()/close().
   display:none, not visibility or opacity, so it leaves 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 ──
   The widget scopes every selector to `.heyhy-*` and has no bare element rules,
   but 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`) and those land on widget content too:
     - 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
       (0,1,1).
     - A declaration on the element beats an INHERITED value at any specificity:
       `th, td { font-size: 13px }` beats the widget's cell size, which comes by
       inheritance from `.heyhy-table-wrap table { font-size: 0.92em }`.
     - A property the widget does not declare: `a { text-decoration: none }` and
       `code { color: var(--muted) }` land unopposed.
   Each is restated below with `.heyhy-root` plus enough path to be out of reach;
   the app's own rules are not changed.

   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. That is how every
   table in this app looks, so it is inherited on purpose. */

/* The widget deliberately underlines links (colour alone is not a distinction),
   and this app's `a { text-decoration: none }` removes it. Hover 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) }` would dim inline code to 65%. The widget
   declares the background and leaves the colour to inherit, so this app's rule
   arrives unopposed. */
.heyhy-root .heyhy-msg code { color: var(--text); font-size: 12px; padding: 1px 5px; }
.heyhy-root .heyhy-msg pre code { color: inherit; }

/* `table { min-width: 720px }` from the data-table block would make every chat
   table need a horizontal scroll in a 400px panel. Zeroing it lets the widget's
   `width: 100%` win. Cells inherit the font-size (see the `1em` restatement
   below), so this is the one place a chat table's text size is set. */
.heyhy-root .heyhy-table-wrap table { min-width: 0; table-layout: auto; font-size: 12.5px; }

/* `tr:last-child td { border-bottom: 0 }` suits a `.table-wrap` table, where the
   wrapper's border closes the grid. A chat table has no frame, so this would
   leave the last row hanging open. */
.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; the header keeps
   its smaller label size in `em`. */
.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, and sticky would make 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 show a pointer.
   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 gives the widget this app's COLOURS, not its
   measurements: the package's own (14px base, 12px gutters, 30px controls, 10px
   radii) are a coherent design that is not this app's. HydraB Telematics mounts
   the same bundle with its own. The `.ai-*` names in the comments below are the
   original app-panel rules each measurement came from, kept because a bare
   number ("32px", "13.5px") reads as arbitrary without them. */

/* .ai-panel: the docked window sits ABOVE the launcher, not over it, both placed
   off the launcher variables. The widget hard-codes right/bottom 20px/84px;
   width and height 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 an overshoot, 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, since unfolding from
   a corner is the DOCKED gesture. */
.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 (--panel-2, the assistant bubble's
   colour) would read as a toolbar. */
.heyhy-root .heyhy-header {
  padding: 14px 16px;
  background: transparent;
  gap: 0;
}
/* .ai-panel-wordmark. A window title at the app's heading size, not the
   package's 700 weight, which at 14px reads as a label. */
.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, as .ai-mark-on-*: 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; hover
   is the brand tint every other icon control uses, not the sunken surface (the
   code-block colour here). */
.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 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 (the tint alone is
   not the boundary), and the assistant's is full-width, so a table or 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. */
.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; these rules use
   the app's fixed 8px rhythm at 13.5px and are 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 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, giving a stray vertical scrollbar;
   the padding is room for macOS's OVERLAY horizontal scrollbar, which 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;
}
.heyhy-root .heyhy-table-wrap th,
.heyhy-root .heyhy-table-wrap td {
  padding: 5px 8px;
  /* `break-word`, not the package's `anywhere`: `anywhere` also volunteers every
     character as a break opportunity ("AV118" becomes "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
   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 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 `.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 draws the fill, ring and 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 have equal 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, and the
   conversation column centred on a wide screen with padding (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; }

/* ════════════════════════════════════════════════════════════════════════
   TOUCH
   Everything below is gated on `pointer: coarse`, so desktop chrome keeps its
   density. One section rather than a line per component, because it is one
   decision: the app was built for a cursor.

   24×24 CSS px is the floor in WCAG 2.5.8 (AA, 2.2); 44×44 is what Apple's HIG
   and Material ask for. 44 marks a target that is comfortable to read and only
   hard to hit; 24 marks one that cannot grow without changing the layout.

   Most 38px controls are handled at the source by --control-h near the top of
   this file. What is here never took that token: icon buttons, checkboxes, and
   inline links acting as buttons. Verify with tests/device (see
   tests/device/FINDINGS.md), not by eye.
   ════════════════════════════════════════════════════════════════════════ */
@media (pointer: coarse) {
  /* Icon-only buttons. #bd-close and #fd-close are the ONLY way out of their
     drawers on a phone, so they must be easy to hit. */
  #menu-toggle,
  #notif-bell,
  #rail-toggle,
  #bd-close,
  #fd-close,
  .icon-btn,
  .gf-icon-btn,
  .fx-open { min-width: 44px; min-height: 44px; }

  /* The account trigger keeps its 34vw cap from the narrow-viewport block above;
     only its height moves. */
  .user-trigger { min-height: 44px; }

  /* Checkboxes ship at the UA's 13×13. 24px is the WCAG floor and as far as these
     can go without re-laying out the filter columns; the <label> beside each is
     also clickable, so the effective target is larger. */
  .faults-check input[type="checkbox"],
  .checkbox-row input[type="checkbox"] { width: 24px; height: 24px; }

  /* Text that behaves like a button (bare inline links and summaries). inline-flex
     plus a min-height grows the hit box without moving the text. */
  .card-vor-link,
  .notif-popover-link,
  #bd-fullpage,
  details#vor-about > summary { display: inline-flex; align-items: center; min-height: 44px; }

  .subs-list .sub-actions button { min-height: 44px; padding-inline: 12px; }
  .faults-chip-x { min-width: 24px; min-height: 24px; }

  /* iOS Safari zooms the page when a focused input's font is under 16px and does
     not zoom back on blur, leaving the user scrolling sideways. 16px is the
     threshold; --control-h above absorbs the extra height. */
  input, select, textarea { font-size: 16px; }
}

/* Scroll chaining: `contain` stops a drawer, popover or table scroller handing
   its remaining momentum to the page behind it, and stops a downward drag at the
   TOP of one firing Chrome Android's pull-to-refresh. No change on desktop. */
.bus-detail,
.fleet-detail,
.warranty-detail,
.group-multi-panel,
.user-menu-panel,
#notif-popover,
.table-wrap { overscroll-behavior: contain; }

/* A picker panel cannot be anchored to its trigger on a phone. It is absolutely
   positioned at the trigger's left edge and capped at
   `min(360px, calc(100vw - 24px))`, which caps the WIDTH but not the POSITION
   (the dashboard's Operator trigger starts ~62px in, so 62 + 366 overruns a
   390px screen). positionGroupMultiPanel() (app.js) flips to the right edge,
   finds that off screen too, and un-flips: no trigger-anchored 360px panel fits a
   390px screen. So below the tablet breakpoint the panel spans the viewport. The
   flip class is neutralised here rather than in JS, because app.js measures a
   panel this rule has already taken out of its hands. */
@media (max-width: 700px) {
  .group-multi-panel,
  .group-multi-panel.is-flipped {
    position: fixed;
    left: 8px;
    right: 8px;
    top: auto;
    width: auto;
    min-width: 0;
    max-width: none;
    max-height: 60dvh;
    overflow-y: auto;
  }
}

/* ── Midnight preset, dark: the styling its palette cannot carry ──
   The Hy! Platform control-room look from the Liverpool City Region deck. Chrome
   and cards are lit by a faint white edge and halo, the active nav pill is
   neutral with white text, card and panel links are neutral, and HeyHy! keeps
   the deck's navy panel with a magenta question bubble. `data-preset` is set by
   applyPresetTheme() and removed while a fleet theme is in force; light mode gets
   none of this. Its dark street map is in map/basemap.js (darkMapMatrix in
   theme-presets.js). */
:root[data-preset="midnight"]:not([data-theme="light"]) {
  --midnight-edge: rgba(255, 255, 255, 0.07);
  --midnight-halo: rgba(255, 255, 255, 0.045);
  --shadow-card: 0 0 0 1px var(--midnight-edge), 0 0 18px var(--midnight-halo);
}
:root[data-preset="midnight"]:not([data-theme="light"]) header {
  box-shadow: 0 1px 0 var(--midnight-edge), 0 4px 18px var(--midnight-halo);
}
:root[data-preset="midnight"]:not([data-theme="light"]) .side-menu {
  box-shadow: 1px 0 0 var(--midnight-edge), 4px 0 18px var(--midnight-halo);
}
:root[data-preset="midnight"]:not([data-theme="light"]) .card-vor-link,
:root[data-preset="midnight"]:not([data-theme="light"]) .vh-fullpage-link {
  color: var(--text);
}
:root[data-preset="midnight"]:not([data-theme="light"]) .side-menu-item:hover::before,
:root[data-preset="midnight"]:not([data-theme="light"]) .side-menu-subitem:hover::before {
  background: rgba(255, 255, 255, 0.05);
}
:root[data-preset="midnight"]:not([data-theme="light"]) .side-menu-item.is-active::before,
:root[data-preset="midnight"]:not([data-theme="light"]) .side-menu-subitem.is-active::before {
  background: #1d2127;
  box-shadow: inset 0 0 0 1px var(--midnight-edge), 0 0 12px var(--midnight-halo);
}
:root[data-preset="midnight"]:not([data-theme="light"]) .side-menu-item.is-active,
:root[data-preset="midnight"]:not([data-theme="light"]) .side-menu-subitem.is-active {
  color: var(--text);
}
:root[data-preset="midnight"]:not([data-theme="light"]) .ai-launcher {
  border-color: rgba(255, 255, 255, 0.14);
  box-shadow: 0 0 0 1px var(--midnight-edge), 0 0 22px 4px rgba(255, 255, 255, 0.12);
}
:root[data-preset="midnight"]:not([data-theme="light"]) .ai-launcher:hover,
:root[data-preset="midnight"]:not([data-theme="light"]) .ai-launcher[aria-expanded="true"] {
  border-color: rgba(255, 255, 255, 0.24);
  box-shadow: 0 0 0 1px var(--midnight-edge), 0 0 26px 6px rgba(255, 255, 255, 0.16);
}
:root[data-preset="midnight"]:not([data-theme="light"]) .heyhy-root .heyhy-panel {
  --heyhy-surface: #141f30;
  --heyhy-surface-raised: #19263b;
  --heyhy-surface-sunken: #0f1826;
  --heyhy-border: #263247;
  --heyhy-text-muted: #a4b6ca;
  --heyhy-user-bubble: #2d1e36;
  border-color: #2f3c52;
  box-shadow: 0 18px 48px rgba(0, 0, 0, 0.5), 0 0 22px var(--midnight-halo);
}
:root[data-preset="midnight"]:not([data-theme="light"]) .heyhy-root .heyhy-header,
:root[data-preset="midnight"]:not([data-theme="light"]) .heyhy-root .heyhy-form {
  background: var(--heyhy-surface);
}
:root[data-preset="midnight"]:not([data-theme="light"]) .heyhy-root .heyhy-msg[data-role="user"] {
  border: 1px solid rgba(214, 64, 150, 0.5);
}
:root[data-preset="midnight"]:not([data-theme="light"]) .heyhy-root .heyhy-msg[data-role="assistant"] {
  border: 1px solid var(--heyhy-border);
}
:root[data-preset="midnight"]:not([data-theme="light"]) .heyhy-root .heyhy-send {
  background: transparent;
  border: 1px solid var(--heyhy-border);
  color: var(--primary);
}
:root[data-preset="midnight"]:not([data-theme="light"]) .heyhy-root .heyhy-send:not([disabled]):hover {
  border-color: var(--primary);
}

/* A preset's dark street map (map/basemap.js): the light street tiles through
   the feColorMatrix in the hidden .svg-defs element. */
.basemap-matrix { filter: url(#basemap-matrix-filter); }
.svg-defs { position: absolute; width: 0; height: 0; overflow: hidden; }

/* VOR report: the "All open VOR faults" toggle, first on the actions row. */
.vor-include-open {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  margin-right: auto;
  font-size: 13px;
  cursor: pointer;
}
