/* ==========================================================================
   gridboard — design tokens + component styles

   Every surface, border and text colour is a custom property. That is not
   decoration: the dark theme and the Tweaks panel both work by rewriting these
   variables at runtime, so a hardcoded #fff anywhere becomes a light-mode
   artefact stuck in a dark page. If you need a new colour, add a token.

   Brand tokens (--brand-navy, --signal-red, --warn-amber, --warn-deep,
   --ok-green) are
   overwritten inline on <html> by the Tweaks panel. Everything else is derived
   from them or from the neutral ramp.
   ========================================================================== */

:root {
  /* --- brand (runtime-editable) --- */
  --brand-navy: #14213d;
  --signal-red: #c8323e;
  --warn-amber: #e5a33f;
  /* The fourth state's own colour. Open and Expiring soon share a hue (H36/H32)
     and split on lightness (L57/L40) rather than each taking a hue of their own:
     a third hue squeezed between amber and red lands 1.25:1 against red, which is
     the same colour in a stacked bar and identical under deuteranopia. A
     lightness split survives both, and the ramp green -> amber -> deep -> red
     then rises monotonically, which is what a stacked segment needs. */
  --warn-deep: #b96a12;
  --ok-green: #2e9e5b;

  /* --- neutral ramp --- */
  --bg: #eef1f6;
  --surface: #ffffff;
  --surface-alt: #f4f6f9;
  --surface-hover: #f8fafc;
  --surface-sunken: #f1f5f9;
  --border: #e2e8f0;
  --border-strong: #cbd5e1;

  --text: #1a2233;
  --text-dim: #64748b;
  --text-faint: #94a3b8;
  /* Text drawn on a brand-coloured fill (header, primary buttons, pressed
     pills). Deliberately absent from the dark-theme block below: those fills
     stay navy in both themes, so flipping this to a dark value would paint
     navy-on-navy. */
  --text-on-brand: #ffffff;
  /* The filter bar is a dark band under the navy header in both themes, the
     same way the header itself is. Like --text-on-brand it is deliberately not
     remapped for dark mode — there is nothing to flip it to. */
  --filter-bar: #0e1729;

  /* --- status tints (backgrounds behind status text/badges) --- */
  --tint-ok: #e7f6ed;
  --tint-warning: #fdf3e2;
  --tint-critical: #fdeaec;
  --tint-info: #e6f0fb;
  --tint-severe: #fbeddc;
  --tint-ok-text: #1d6b3c;
  --tint-warning-text: #8a5310;
  --tint-critical-text: #93202a;
  --tint-info-text: #1e4e8c;
  --tint-severe-text: #8a4a0c;

  /* --- shape + motion --- */
  --radius: 12px;
  --radius-sm: 8px;
  --radius-xs: 6px;
  --shadow-card: 0 2px 10px rgba(16, 24, 40, 0.08);
  --shadow-pop: 0 20px 60px rgba(16, 24, 40, 0.2);
  --ring: 0 0 0 2px var(--surface), 0 0 0 4px var(--brand-navy);
  --transition: 150ms ease;

  /* --- density (Tweaks panel swaps these) --- */
  --density-row: 44px;
  --density-pad: 20px 24px;
  --density-font: 0.78rem;

  /* System stacks only. There is no bundler and no CDN, so a webfont would have
     to be vendored as a woff2 in this repo; naming Inter / IBM Plex Mono here
     without shipping them just meant every install silently fell through to the
     next entry. If a brand face is ever required, add an @font-face with a
     self-hosted file and put its name back at the front. */
  --font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  --font-data: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;

  --grid-gap: 16px;
  --page-pad: 40px;

  /* The rail's width and the shell's left padding are the same number by
     construction, which is the whole reason this is a token. The reference
     implementation writes it six times — 220 on the header and again on the
     board, 58 for the collapsed class and again in a media query — and that is
     why its own banner never shifted with the rest of the page. */
  --nav-w: 220px;

  /* One grid row. A tile's footprint is a whole number of these, so this is the
     height of the smallest tile on the board and half the height of a tall one.
     A token rather than a literal because density has to be able to move it. */
  --row-h: 230px;

  /* What a chart box asks for before the grid decides. The row height above is
     what a tile is actually given, and `.chart-area` flexes into whatever the
     head leaves of it — these two are the size the box falls back to when it is
     not inside `.tile-grid` at all, and the reason a chart never sizes itself to
     its content: a chart that did made two bar tiles on the same row two
     different heights purely because one had more categories. */
  --chart-h: 240px;
  --chart-h-sm: 170px;
  /* Named because two things have to agree on it: the scrollbar itself and
     anything laid out beside a scrolling box. */
  --scrollbar-w: 6px;
}

:root[data-density="compact"] {
  --density-row: 34px;
  --density-pad: 12px 16px;
  --density-font: 0.74rem;
  --grid-gap: 12px;
  --row-h: 190px;
  --chart-h: 190px;
  --chart-h-sm: 140px;
}

:root[data-shadows="off"] {
  --shadow-card: none;
}

/* --------------------------------------------------------------- dark theme --
   Dark is the same component set with a remapped ramp. Status colours are
   desaturated ~10% so they stay legible instead of glowing on a dark surface. */
:root[data-theme="dark"] {
  --bg: #0d1220;
  --surface: #161d2f;
  --surface-alt: #1b2439;
  --surface-hover: #212b42;
  --surface-sunken: #111827;
  --border: #2a3550;
  --border-strong: #3a4767;

  --text: #e8edf7;
  --text-dim: #9aa7bf;
  --text-faint: #6f7d97;

  --tint-ok: #16321f;
  --tint-warning: #35260f;
  --tint-critical: #3a1519;
  --tint-info: #14243c;
  --tint-severe: #38210c;
  --tint-ok-text: #6cd394;
  --tint-warning-text: #e0ab5f;
  --tint-critical-text: #f08c95;
  --tint-info-text: #7db2ea;
  --tint-severe-text: #e09a55;

  --shadow-card: 0 2px 10px rgba(0, 0, 0, 0.4);
  --shadow-pop: 0 20px 60px rgba(0, 0, 0, 0.55);
  --ring: 0 0 0 2px var(--surface), 0 0 0 4px var(--text-dim);
}

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: var(--font-ui);
  background: var(--bg);
  color: var(--text);
  -webkit-font-smoothing: antialiased;
  min-height: 100vh;
}

/* Hides the app until the auth gate resolves, so a logged-out visitor never
   sees a flash of dashboard chrome. */
body.booting .app-shell {
  display: none;
}
body.booting {
  background: var(--surface);
}

/* The signed-out failure page. `/login` and `/callback` are navigations, so a
   failure comes back as `?auth_error=` on a page with no shell, no toasts and
   no header — this card is the entire surface, so it carries the header's red
   rule to stay recognisably the same product. */
.auth-error {
  min-height: 100vh;
  display: grid;
  place-items: center;
  padding: 40px 20px;
}
.auth-error-card {
  max-width: 440px;
  padding: 28px;
  text-align: center;
  background: var(--surface);
  border: 1px solid var(--border);
  border-top: 3px solid var(--signal-red);
  border-radius: var(--radius);
  box-shadow: var(--shadow-card);
}
.auth-error-card h1 {
  font-size: 1rem;
  font-weight: 800;
  letter-spacing: 0.6px;
  text-transform: uppercase;
  margin-bottom: 10px;
}
.auth-error-card p {
  font-size: 0.84rem;
  line-height: 1.6;
  color: var(--text-dim);
  margin-bottom: 20px;
}

button,
input,
select,
textarea {
  font-family: inherit;
  color: inherit;
}

button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
[tabindex]:focus-visible {
  outline: 2px solid var(--brand-navy);
  outline-offset: 2px;
}
:root[data-theme="dark"] button:focus-visible,
:root[data-theme="dark"] [tabindex]:focus-visible {
  outline-color: var(--text-dim);
}

.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}

::-webkit-scrollbar {
  width: var(--scrollbar-w);
  height: var(--scrollbar-w);
}
::-webkit-scrollbar-track {
  background: transparent;
}
::-webkit-scrollbar-thumb {
  background: var(--border-strong);
  border-radius: 10px;
}

/* ================================================================== header == */

/* Everything in here is drawn on navy in BOTH themes, so the white values below
   are deliberately literal rather than tokens: a token that flips with the theme
   would paint dark-on-dark here. `--text-on-brand` is the one exception and is
   itself defined not to flip. */
/* The preview banner. Above the header and outside it, because it is a
   statement about the deployment rather than part of the product's chrome — and
   because it has to be the first thing on the page even on a board that never
   finishes loading.

   Amber and not red: red on this board means a finding is expired, and a page
   whose warning strip is the same colour as its worst status teaches a reader
   to read one as the other. Deep amber is the only warm token not already
   spoken for by the traffic light.

   Not sticky. It is standing context, not a control, and a strip that followed
   the scroll would cost every tile a row of height on a phone for a sentence
   already read. */
.release-banner {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
  padding: 8px var(--page-pad);
  background: var(--warn-deep);
  color: #fff;
  font-size: 0.82rem;
  line-height: 1.4;
}
.release-badge {
  font-family: var(--font-data);
  font-size: 0.72rem;
  font-weight: 700;
  letter-spacing: 0.02em;
  padding: 2px 8px;
  border-radius: 999px;
  background: rgba(0, 0, 0, 0.28);
  white-space: nowrap;
}
.release-text {
  font-weight: 600;
  min-width: 0;
}
/* A button and not an anchor: there is no URL to give it. Underlined anyway,
   because a link that only looks like one on hover is one nobody finds. */
.release-link {
  margin-inline-start: auto;
  border: 0;
  background: none;
  color: inherit;
  font: inherit;
  font-weight: 700;
  text-decoration: underline;
  text-underline-offset: 3px;
  cursor: pointer;
  padding: 2px 4px;
  border-radius: 4px;
}
.release-link:hover {
  background: rgba(0, 0, 0, 0.18);
}
.release-link:focus-visible {
  outline: 2px solid #fff;
  outline-offset: 1px;
}

.release-notes {
  display: flex;
  flex-direction: column;
  gap: 18px;
}
.release-current {
  margin: 0;
  color: var(--text-dim);
  font-size: 0.82rem;
}
.release-group h3 {
  margin: 0 0 6px;
  font-size: 0.78rem;
  font-weight: 700;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--text-dim);
}
.release-group ul {
  margin: 0;
  padding: 0;
  list-style: none;
  display: flex;
  flex-direction: column;
  gap: 6px;
}
.release-group li {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
  border-bottom: 1px solid var(--border);
  padding-bottom: 6px;
}
.release-subject {
  min-width: 0;
}
.release-sha {
  font-family: var(--font-data);
  font-size: 0.72rem;
  color: var(--text-dim);
  white-space: nowrap;
}

.header {
  background: var(--brand-navy);
  color: var(--text-on-brand);
  padding: 18px var(--page-pad) 0;
  border-bottom: 3px solid var(--signal-red);
}

.header-top {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  padding-bottom: 16px;
}

.header-brand {
  display: flex;
  align-items: center;
  gap: 14px;
  min-width: 0;
}
.brand-logo {
  height: 38px;
  width: auto;
  max-width: 150px;
  object-fit: contain;
  flex: none;
}

.header-title {
  display: flex;
  flex-direction: column;
  gap: 1px;
  min-width: 0;
}
.header-eyebrow,
.header-subtitle {
  font-size: 0.62rem;
  font-weight: 700;
  letter-spacing: 1.4px;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.55);
  /* Truncate rather than wrap: both lines are installation-configurable, so a
     long one would otherwise stack and shove the header-right controls off. */
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.header-eyebrow:empty,
.header-subtitle:empty {
  display: none;
}
.header-title h1 {
  font-size: 1.35rem;
  font-weight: 800;
  letter-spacing: 0.6px;
  text-transform: uppercase;
  white-space: nowrap;
  line-height: 1.15;
  /* Truncates for the same reason the two lines around it do, and it was the
     one that did not: `nowrap` without an overflow rule does not shrink the
     brand box, it draws the title straight over the buttons to the right of it.
     An installation title long enough to do that is not exotic — "FLEET CONTROL
     ROOM" does it on a phone. */
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
}

.header-right {
  display: flex;
  align-items: center;
  gap: 10px;
  flex: none;
}

.updated-chip {
  font-size: 0.62rem;
  color: rgba(255, 255, 255, 0.55);
  display: flex;
  align-items: center;
  gap: 6px;
  white-space: nowrap;
  letter-spacing: 0.8px;
  text-transform: uppercase;
  font-weight: 700;
  margin-right: 6px;
}
.updated-chip #updatedText {
  color: rgba(255, 255, 255, 0.9);
  font-family: var(--font-data);
  letter-spacing: 0;
  text-transform: none;
}
.updated-dot {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--ok-green);
  flex: none;
}

.user-chip {
  display: grid;
  place-items: center;
  padding: 0;
  border-radius: 50%;
  background: none;
  border: none;
  cursor: pointer;
  color: var(--text-on-brand);
}
.avatar {
  width: 34px;
  height: 34px;
  border-radius: 50%;
  background: var(--signal-red);
  display: grid;
  place-items: center;
  font-size: 0.72rem;
  font-weight: 800;
  letter-spacing: 0.5px;
  transition: filter var(--transition);
}
.user-chip:hover .avatar {
  filter: brightness(1.12);
}

/* Header buttons sit on navy, so they get their own translucent treatment. */
.hbtn {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 7px 12px;
  border-radius: var(--radius-xs);
  border: 1px solid rgba(255, 255, 255, 0.18);
  background: rgba(255, 255, 255, 0.06);
  color: var(--text-on-brand);
  font-size: 0.76rem;
  font-weight: 600;
  cursor: pointer;
  transition: background var(--transition), border-color var(--transition);
}
.hbtn.round {
  width: 34px;
  height: 34px;
  padding: 0;
  border-radius: 50%;
  justify-content: center;
}
.hbtn:hover {
  background: rgba(255, 255, 255, 0.16);
}
.hbtn.active {
  background: var(--signal-red);
  border-color: var(--signal-red);
}
.hbtn svg {
  width: 15px;
  height: 15px;
}

/* ==================================================================== tabs == */

/* Tabs live inside the navy header, under the brand row — so like the header
   they are drawn against navy in both themes and use literal whites. */
.tabbar {
  display: flex;
  gap: 26px;
  align-items: flex-end;
  overflow-x: auto;
  scrollbar-width: none;
}
.tabbar::-webkit-scrollbar {
  display: none;
}

.tab {
  padding: 0 0 11px;
  border: none;
  border-bottom: 3px solid transparent;
  /* The header's own 3px red rule sits directly beneath, so the active
     underline is pulled down onto it rather than stacking above it. */
  margin-bottom: -3px;
  background: transparent;
  color: rgba(255, 255, 255, 0.55);
  font-size: 0.72rem;
  font-weight: 700;
  letter-spacing: 1.1px;
  text-transform: uppercase;
  cursor: pointer;
  white-space: nowrap;
  transition: color var(--transition), border-color var(--transition);
}
.tab:hover {
  color: var(--text-on-brand);
}
.tab[aria-selected="true"] {
  color: var(--text-on-brand);
  border-bottom-color: var(--text-on-brand);
}
.tab-add {
  color: rgba(255, 255, 255, 0.45);
  letter-spacing: 0;
}
/* The active tab's own menu. Pulled back into the bar's 26px gap, which is the
   space between two tabs and not space inside one — left at full width it reads
   as a third control belonging to nothing. */
.tab-menu {
  margin-left: -19px;
  font-size: 0.95rem;
  line-height: 0.8;
}

/* The editing row. Its own block rather than inline styles because it now
   carries a mode switch and a warning line, and a two-row flex with a hint
   under it is not something to spell out at a call site. */
.edit-toolbar {
  display: flex;
  flex-direction: column;
  gap: 8px;
  margin-bottom: 14px;
  padding: 10px 12px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface-2, var(--surface));
}
.scope-switch {
  display: inline-flex;
  gap: 4px;
  padding: 3px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
}
.scope-switch .btn {
  border: none;
}

/* ============================================================== filter bar == */

/* Everything in this section is shown and hidden by toggling `hidden` from
   filters.js, and every one of these rules sets its own `display` — which beats
   the user-agent's `[hidden] { display: none }`. Without this the panels never
   close: all four open at once, stacked on top of each other. */
.fx-pop[hidden],
.fx-chiprow[hidden],
.fx-block[hidden],
.fx-opt[hidden],
.fx-search[hidden],
.fx-pop-head[hidden] {
  display: none;
}

/* A dark band directly under the navy header, closed by the same red rule the
   header carries. Everything in the two rows is drawn on --filter-bar in both
   themes, so the literal whites below are intentional (see the header block).
   The panels are not: they leave the band and land over page content, so they
   use the ordinary surface tokens and retheme normally.

   The trigger row is a fixed height whatever is selected. That is the point of
   the separate chip row — a wrapping summary in the same row as the triggers
   would move the whole page down as somebody narrows the board. */
/* ================================================================== sidebar */

/* One rule shifts the whole page, and it is padding on the shell rather than a
   margin on each band. Two reasons, and both of them are a bug avoided:
   `.page` is `margin: 0 auto` and a `margin-left` would fight its centring;
   and `.app-shell` must never become a grid, because
   `test_the_responsive_ladder_clamps_every_width_it_narrows_past` searches every
   max-width block for `grid-template-columns` **unscoped to any selector** —
   shifting the shell that way inside the rail's media query turns that test red
   with a message about tile widths, pointing at the wrong file entirely. */
.app-shell {
  padding-left: var(--nav-w);
}

.sidebar {
  position: fixed;
  left: 0;
  top: 0;
  bottom: 0;
  width: var(--nav-w);
  /* Above nothing in particular and below `.fx-scrim` (99) on purpose: while a
     filter panel is open the rail dims with the board, and a click on it closes
     the panel rather than navigating — which is what a click anywhere else
     outside it already does. */
  z-index: 50;
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding: 14px 0;
  /* Navy, and it is the same call the filter bar's block already argues: this
     app's chrome is the brand band, in both themes, with literal whites drawn
     on it. The reference implementation this follows keeps its rail on the
     surface colour — but its rail is the only chrome it has, and gridboard has
     two navy bands across the top. A white column between them reads as the
     header having been pushed aside rather than as one frame around the board,
     and it is the one part of the app that would not retheme with the rest. */
  background: var(--brand-navy);
  color: var(--text-on-brand);
  overflow-y: auto;
  transition: width var(--transition);
}

/* No rule under it. The header's own bottom edge is 100px lower and the filter
   bar's lower still; a third horizontal line on the left third of the page
   agrees with neither, and there is nothing above this one to separate. */
.side-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  padding: 0 12px 6px;
}

/* The brand, twice, and the rail state picks which one draws. The lockup and
   the mark are two files rather than one image squeezed by CSS, because a
   220px wordmark scaled into a 58px column is unreadable at any size the
   column can hold — the collapsed rail gets the funnel alone. */
.side-brand {
  display: flex;
  align-items: center;
  min-width: 0;
}

.side-logo-full {
  height: 32px;
  width: auto;
  max-width: 150px;
  object-fit: contain;
}

.side-logo-mark {
  display: none;
  height: 26px;
  width: auto;
}

body.nav-rail .side-head {
  flex-direction: column;
  align-items: center;
  gap: 6px;
  padding: 0 0 6px;
}

body.nav-rail .side-logo-full {
  display: none;
}

body.nav-rail .side-logo-mark {
  display: block;
}

.side-collapse {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 28px;
  height: 28px;
  border: none;
  border-radius: 7px;
  background: transparent;
  color: rgba(255, 255, 255, 0.5);
  cursor: pointer;
  transition: background var(--transition), color var(--transition);
}

.side-collapse:hover {
  background: rgba(255, 255, 255, 0.1);
  color: var(--text-on-brand);
}

.side-collapse svg {
  transform: rotate(90deg);
  transition: transform var(--transition);
}

body.nav-rail .side-collapse svg {
  transform: rotate(-90deg);
}

.side-nav {
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: 0 10px;
}

/* Drawn on the band, so the whites are literal for the reason the filter bar's
   are — a token that retheming moved would be a light control on navy in dark
   mode. The tint pair the rest of the app selects with (`--tint-info`) is a
   pale blue meant for a white surface and disappears into this one. */
.side-nav-item {
  position: relative;
  display: flex;
  align-items: center;
  gap: 11px;
  width: 100%;
  padding: 9px 12px;
  border: none;
  border-radius: 8px;
  background: transparent;
  color: rgba(255, 255, 255, 0.72);
  font: inherit;
  font-size: 0.86rem;
  font-weight: 600;
  text-align: left;
  cursor: pointer;
  transition: background var(--transition), color var(--transition);
}

.side-nav-item:hover {
  background: rgba(255, 255, 255, 0.08);
  color: var(--text-on-brand);
}

.side-nav-item.active {
  background: rgba(255, 255, 255, 0.12);
  color: var(--text-on-brand);
}

/* The red rule the header closes with, stood on its end. Contrast alone would
   carry it on a wide rail, but the icon rail hides the label, and a highlight
   distinguished only by a slightly lighter navy behind a 17px glyph is one an
   eye skips — this is the state that survives being 58px wide. */
.side-nav-item.active::before {
  content: "";
  position: absolute;
  left: 0;
  top: 6px;
  bottom: 6px;
  width: 3px;
  border-radius: 0 3px 3px 0;
  background: var(--signal-red);
}

.side-nav-item svg {
  flex: none;
}

/* The manual rail. This rule is the one that actually narrows it — hiding the
   labels without it leaves a 220px column of centred icons, which reads as a
   toggle that half worked. */
body.nav-rail {
  --nav-w: 58px;
}

body.nav-rail .side-label {
  display: none;
}

body.nav-rail .side-nav-item {
  justify-content: center;
  padding: 9px 0;
}

/* The rail is desktop-only, and this is the whole of that.
   It used to narrow to an icon rail here instead, at 900 — the width where the
   filter bar starts trimming. It is gone rather than narrow now, and the width
   moved to 1100 with it, because 1100 is what `desktop` *means*: it is the
   boundary `store.SCREEN_QUERIES` draws, and a rail that survived onto tablets
   would be 58px of navigation spent on a screen the narrow-screen menu is going
   to serve properly. Until that menu exists a narrow window is the board and
   nothing else, which is what it was before the rail.

   `--nav-w: 0` rather than only `display: none`, because the shell's left
   padding is read from the token and a hidden rail with a 220px gutter beside
   it is the same bug the token exists to prevent.

   Welding nav chrome to one of the board's own rungs is a thing the rail's test
   used to refuse, and the reason has not changed — it is that a *narrowing* at
   700 or 1100 that the script has no name for is a band nothing can be scoped
   to. This block declares no `grid-template-columns` and restyles no
   `.tile-grid`, so it stays invisible to both breakpoint tests; what it shares
   with 1100 is the definition of `desktop`, which is the point rather than a
   collision. */
@media (max-width: 1100px) {
  /* Both, and `body.nav-rail` is not belt and braces. The manual rail sets the
     token on `<body>` and this block sets it on `<html>`, so the shell — which
     is inside the body — reads 58px from its own parent and the `:root` rule
     never reaches it. The rail is hidden and a 58px gutter stays behind: an
     empty column on every tablet belonging to anyone who has ever collapsed
     the rail once, and `navRail` is a stored preference, so it follows them
     from the desktop they collapsed it on. */
  :root,
  body.nav-rail {
    --nav-w: 0px;
  }
  .sidebar {
    display: none;
  }
}

.filters-sticky {
  position: sticky;
  top: 0;
  z-index: 100;
  background: var(--filter-bar);
  color: rgba(255, 255, 255, 0.9);
  border-bottom: 3px solid var(--signal-red);
}

.fx-bar {
  display: flex;
  align-items: center;
  gap: 8px;
  min-height: 54px;
  padding: 9px var(--page-pad);
}

.fx-triggers {
  display: flex;
  align-items: center;
  gap: 8px;
  min-width: 0;
}

.fx-spacer {
  flex: 1;
  min-width: 0;
}

.fx-empty {
  font-size: 0.62rem;
  font-weight: 700;
  letter-spacing: 1.1px;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.45);
  flex-shrink: 0;
}

/* -------------------------------------------------------- the fold --------- */

/* Folded, the same element stops being the "showing all" placeholder and
   becomes the summary of what is filtered — a sentence, not a label, so the
   uppercase letter-spacing comes off and it is allowed to shrink and ellipsise
   rather than push Views off the end of a narrow bar. */
.filters-sticky.is-collapsed .fx-empty {
  font-size: 0.72rem;
  font-weight: 600;
  letter-spacing: 0;
  text-transform: none;
  color: rgba(255, 255, 255, 0.72);
  flex: 0 1 auto;
  min-width: 0;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.fx-toggle {
  flex-shrink: 0;
  display: inline-flex;
  align-items: center;
  gap: 6px;
}

/* The caret points at the row it controls: down while the row is under it,
   right while there is nothing to point down at. `.fx-caret` carries the
   transition already. */
.filters-sticky.is-collapsed .fx-toggle .fx-caret {
  transform: rotate(-90deg);
}

/* The row's own buttons inherit .btn, which is built for light surfaces.
   `flex-shrink: 0` because the triggers are the ones that give way when the bar
   runs out of room — a squashed Views button helps nobody. */
.filters-sticky .btn {
  flex-shrink: 0;
  background: rgba(255, 255, 255, 0.07);
  border-color: rgba(255, 255, 255, 0.2);
  color: rgba(255, 255, 255, 0.9);
  text-transform: uppercase;
  letter-spacing: 0.8px;
  font-size: 0.66rem;
}
.filters-sticky .btn:hover {
  background: rgba(255, 255, 255, 0.16);
  color: #fff;
}

/* ---------------------------------------------------------------- triggers -- */

.fx-menu {
  position: relative;
  flex-shrink: 0;
}

.fx-trigger {
  display: inline-flex;
  align-items: center;
  gap: 9px;
  padding: 7px 12px;
  border-radius: 9px;
  border: 1px solid rgba(255, 255, 255, 0.16);
  background: rgba(255, 255, 255, 0.08);
  color: #fff;
  font: inherit;
  cursor: pointer;
  transition: background var(--transition), border-color var(--transition);
}
.fx-trigger:hover {
  background: rgba(255, 255, 255, 0.16);
}
.fx-trigger > svg:first-child {
  opacity: 0.65;
  flex: none;
}

/* Two lines: what the filter is, over what it is currently set to. The key line
   is small enough that it reads as a caption rather than competing with the
   value, which is the thing being scanned. */
.fx-trigger-txt {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 1px;
  line-height: 1.1;
  min-width: 0;
}
.fx-trigger-k {
  font-size: 0.53rem;
  font-weight: 700;
  letter-spacing: 1.2px;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.55);
  white-space: nowrap;
}
.fx-trigger-v {
  font-size: 0.78rem;
  font-weight: 650;
  white-space: nowrap;
  max-width: 190px;
  overflow: hidden;
  text-overflow: ellipsis;
}

.has-value > .fx-trigger {
  border-color: rgba(255, 255, 255, 0.3);
  background: rgba(255, 255, 255, 0.14);
}
.has-value > .fx-trigger .fx-trigger-k {
  color: rgba(255, 255, 255, 0.75);
}
.is-open > .fx-trigger {
  background: var(--signal-red);
  border-color: var(--signal-red);
  box-shadow: 0 3px 12px rgba(200, 16, 46, 0.35);
}
.is-open > .fx-trigger .fx-trigger-k {
  color: rgba(255, 255, 255, 0.8);
}
.is-open > .fx-trigger > svg:first-child {
  opacity: 1;
}

.fx-caret {
  opacity: 0.6;
  flex: none;
  transition: transform 0.2s;
}
.is-open > .fx-trigger .fx-caret {
  transform: rotate(180deg);
  opacity: 1;
}

/* ------------------------------------------------------------------- chips -- */

.fx-chiprow {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 0 var(--page-pad) 9px;
}
.fx-chiprow-k {
  font-size: 0.53rem;
  font-weight: 700;
  letter-spacing: 1.3px;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.4);
  flex-shrink: 0;
}
.fx-chips {
  display: flex;
  align-items: center;
  gap: 6px;
  flex: 1;
  min-width: 0;
  flex-wrap: wrap;
}

/* A chip is the filter's off switch, so the whole pill is the button and the ×
   is decoration on it — a 11px hit target next to a 12px label is a miss. */
.fx-chip {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  padding: 5px 9px 5px 11px;
  border-radius: 999px;
  border: 1px solid rgba(255, 255, 255, 0.24);
  background: rgba(255, 255, 255, 0.12);
  color: #fff;
  font: inherit;
  font-size: 0.75rem;
  cursor: pointer;
  max-width: 340px;
  transition: background var(--transition), border-color var(--transition);
}
.fx-chip:hover {
  background: rgba(255, 255, 255, 0.22);
  border-color: var(--signal-red);
}
.fx-chip-k {
  font-size: 0.56rem;
  font-weight: 700;
  letter-spacing: 0.9px;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.6);
  flex-shrink: 0;
}
.fx-chip-v {
  font-weight: 600;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.fx-chip svg {
  color: rgba(255, 255, 255, 0.6);
  flex: none;
}

.fx-clear {
  flex-shrink: 0;
  background: none;
  border: 1px solid rgba(255, 255, 255, 0.18);
  color: rgba(255, 255, 255, 0.8);
  font-family: var(--font-data);
  font-size: 0.62rem;
  letter-spacing: 0.9px;
  text-transform: uppercase;
  padding: 7px 11px;
  border-radius: var(--radius-xs);
  cursor: pointer;
}
.fx-clear:hover {
  background: rgba(255, 255, 255, 0.12);
  color: #fff;
}

/* ------------------------------------------------------------------ panels -- */

/* The scrim is what makes an open panel modal-ish without being a modal: it
   catches the click that closes, and dims the board so the panel is obviously
   the thing being used. Below .filter-pop's own layer and above everything the
   page draws; the real .overlay sits higher still. */
.fx-scrim {
  position: fixed;
  inset: 0;
  z-index: 99;
  background: rgba(6, 18, 36, 0.4);
  backdrop-filter: blur(2px);
  animation: fx-fade 0.16s ease-out;
}
@keyframes fx-fade {
  from {
    opacity: 0;
  }
}

.fx-pop {
  position: absolute;
  z-index: 200;
  top: calc(100% + 7px);
  left: 0;
  min-width: 280px;
  max-height: min(76vh, 660px);
  display: flex;
  flex-direction: column;
  gap: 12px;
  padding: 14px;
  border-radius: var(--radius);
  background: var(--surface);
  color: var(--text);
  border: 1px solid var(--border);
  border-top: 3px solid var(--signal-red);
  box-shadow: 0 22px 54px rgba(11, 37, 69, 0.3);
  animation: fx-drop 0.18s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes fx-drop {
  from {
    opacity: 0;
    transform: translateY(-8px);
  }
}
/* A roster needs the room to lay its groups out side by side — and the width is
   set by the largest group, not by taste. Subtract this element's own padding,
   the class column and the column gap, and what is left has to divide into as
   many chip tracks as the biggest class has members, or that class wraps one
   ship onto a line of its own and the panel reads as broken. At 1180 it did.

   The `max-width` here is the value before anything is measured. Fitting the
   panel to the window is `placeOpenPop`'s job in filters.js, because this
   element is sized against the viewport and positioned against its trigger:
   1440px starting at the second chip in the bar ends several hundred pixels
   past the right edge, and no stylesheet can shift it back by the overflow. */
.fx-pop-wide {
  width: 1440px;
  max-width: calc(100vw - 40px);
}

.fx-pop-body {
  display: flex;
  flex-direction: column;
  gap: 12px;
  min-height: 0;
  overflow-y: auto;
}
.fx-pop-head {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
  flex-shrink: 0;
}
.fx-hint {
  margin-left: auto;
  font-size: 0.6rem;
  letter-spacing: 0.6px;
  text-transform: uppercase;
  color: var(--text-faint);
}
.fx-pop-foot {
  display: flex;
  align-items: center;
  gap: 10px;
  padding-top: 10px;
  border-top: 1px solid var(--border);
  flex-shrink: 0;
}
.fx-pop-note {
  display: flex;
  align-items: center;
  gap: 10px;
  font-size: 0.68rem;
  color: var(--text-dim);
}
.fx-pop-count {
  font-family: var(--font-data);
  font-size: 0.66rem;
  color: var(--text-faint);
}
.fx-done {
  margin-left: auto;
  padding: 8px 20px;
  border: none;
  border-radius: var(--radius-sm);
  background: var(--signal-red);
  color: var(--text-on-brand);
  font: inherit;
  font-size: 0.78rem;
  font-weight: 650;
  cursor: pointer;
}
.fx-reset {
  background: none;
  border: none;
  padding: 6px 8px;
  border-radius: var(--radius-xs);
  color: var(--text-faint);
  font-family: var(--font-data);
  font-size: 0.62rem;
  letter-spacing: 0.9px;
  text-transform: uppercase;
  cursor: pointer;
}
.fx-reset:hover {
  color: var(--signal-red);
  background: var(--surface-hover);
}

.fx-sec {
  display: flex;
  flex-direction: column;
  gap: 6px;
}
.fx-sec-label {
  font-size: 0.56rem;
  font-weight: 700;
  letter-spacing: 1.3px;
  text-transform: uppercase;
  color: var(--text-faint);
}

.pop-loading {
  padding: 8px 6px;
  font-size: 0.76rem;
  color: var(--text-dim);
}

/* --------------------------------------------------------- search + toggles -- */

.fx-search {
  display: flex;
  align-items: center;
  gap: 7px;
  padding: 6px 10px;
  min-width: 210px;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface-sunken);
  color: var(--text-faint);
}
.fx-search-input {
  border: none;
  outline: none;
  background: none;
  font: inherit;
  font-size: 0.78rem;
  color: var(--text);
  width: 100%;
}

.fx-allbtn {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 6px 12px;
  border-radius: var(--radius-sm);
  border: 1.5px solid var(--border-strong);
  background: var(--surface);
  color: var(--text);
  font: inherit;
  font-size: 0.78rem;
  font-weight: 650;
  cursor: pointer;
  transition: background var(--transition), border-color var(--transition);
}
.fx-allbtn:hover {
  border-color: var(--brand-navy);
}
.fx-allbtn-box {
  display: grid;
  place-items: center;
  width: 17px;
  height: 17px;
  border-radius: 5px;
  border: 1.5px solid var(--border-strong);
  background: var(--surface-sunken);
  color: transparent;
}
.fx-allbtn-count {
  font-family: var(--font-data);
  font-size: 0.62rem;
  color: var(--text-faint);
}
.fx-allbtn.is-on {
  background: var(--brand-navy);
  border-color: var(--brand-navy);
  color: var(--text-on-brand);
}
.fx-allbtn.is-on .fx-allbtn-box {
  background: #fff;
  border-color: #fff;
  color: var(--brand-navy);
}
.fx-allbtn.is-on .fx-allbtn-count {
  color: rgba(255, 255, 255, 0.7);
}

/* -------------------------------------------------------- groups + options -- */

.fx-blocks {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

/* Each group carries its own accent, derived from its name in filters.js. The
   hue is set inline as --chip-hue; everything here is stated in terms of it, so
   a group nobody anticipated still comes out styled. */
.fx-block {
  --chip-hue: 210;
  display: grid;
  grid-template-columns: 220px 1fr;
  gap: 8px;
  align-items: start;
}
.fx-block-head {
  display: flex;
  align-items: center;
  gap: 8px;
  width: 100%;
  padding: 5px 9px;
  border-radius: var(--radius-sm);
  border: 1.5px solid var(--border);
  background: var(--surface);
  font: inherit;
  text-align: left;
  cursor: pointer;
  transition: background var(--transition), border-color var(--transition);
}
.fx-block-head.is-static {
  cursor: default;
  border-style: dashed;
}
.fx-block-head:not(.is-static):hover {
  border-color: oklch(58% 0.15 var(--chip-hue));
}
.fx-block-box {
  display: grid;
  place-items: center;
  width: 16px;
  height: 16px;
  flex: none;
  border-radius: 5px;
  border: 1.5px solid var(--border-strong);
  background: var(--surface-sunken);
  color: transparent;
}
.fx-block-txt {
  display: flex;
  flex-direction: column;
  gap: 1px;
  min-width: 0;
}
.fx-block-name {
  font-size: 0.74rem;
  font-weight: 700;
  line-height: 1.15;
  color: var(--text);
}
.fx-block-kind {
  font-family: var(--font-data);
  font-size: 0.55rem;
  letter-spacing: 0.9px;
  color: var(--text-faint);
}
.fx-block-head.is-on,
.fx-block-head.is-partial {
  border-color: oklch(52% 0.16 var(--chip-hue));
  background: oklch(62% 0.13 var(--chip-hue) / 0.1);
}
.fx-block-head.is-on .fx-block-box,
.fx-block-head.is-partial .fx-block-box {
  background: oklch(52% 0.16 var(--chip-hue));
  border-color: oklch(52% 0.16 var(--chip-hue));
  color: #fff;
}
.fx-block-head.is-on .fx-block-kind,
.fx-block-head.is-partial .fx-block-kind {
  color: oklch(45% 0.16 var(--chip-hue));
}

/* Wide enough for the two-line option: an eyebrow that ellipses on the first
   word is worse than no eyebrow.

   `auto-fill`, never `auto-fit`. A class of one — Luminosa — is a real row here,
   and `auto-fit` collapses the empty tracks and stretches that single ship into
   a banner spanning the panel. Keeping the track count means a lone chip is the
   same size as every other chip, which is what makes the grid read as a grid.

   Nor is the count hard-coded. `repeat(5, ...)` would lay this fleet out
   perfectly and put the shape of it in the stylesheet; a class that gains a
   sixth ship should reflow rather than overflow.

   180 rather than 190 because of the scrollbar. On a 1280 viewport the panel is
   1240, and after padding, the class column and the gap that leaves 984 — or
   969 once a classic 15px scrollbar takes its cut, which is one pixel short of
   the 970 five 190px tracks need. Five is the largest class, so that pixel is
   the difference between the panel working and the complaint that started this.
   The scrollbar half of that arithmetic is no longer load-bearing — the cap
   `placeOpenPop` applies is measured against `clientWidth`, which has never
   counted the gutter — but 180 is still the right track and now has room to
   spare rather than one pixel of it. */
.fx-opt-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
  gap: 5px;
}
/* A short list is picked from, not scanned: give the options room to be read. */
.fx-opt-grid-wide {
  grid-template-columns: repeat(2, minmax(140px, 1fr));
}
.fx-opt-list {
  display: grid;
  gap: 5px;
  max-height: 320px;
  overflow-y: auto;
}

.fx-opt {
  display: flex;
  align-items: center;
  gap: 7px;
  padding: 6px 9px;
  border-radius: 7px;
  border: 1.5px solid var(--border);
  background: var(--surface);
  color: var(--text-dim);
  font: inherit;
  text-align: left;
  cursor: pointer;
  transition: background var(--transition), border-color var(--transition);
}
.fx-opt:hover {
  border-color: oklch(58% 0.15 var(--chip-hue, 250));
  color: var(--text);
}
.fx-opt.is-on {
  border-color: oklch(52% 0.16 var(--chip-hue, 250));
  background: oklch(62% 0.13 var(--chip-hue, 250) / 0.13);
  color: var(--text);
}
/* Outside a group there is no hue to tint with, so the selected state falls
   back to the same navy fill every other picked control in the app uses. */
.fx-opt-grid-wide .fx-opt.is-on,
.fx-opt-list .fx-opt.is-on {
  background: var(--brand-navy);
  border-color: var(--brand-navy);
  color: var(--text-on-brand);
}
.fx-opt-txt {
  display: flex;
  flex-direction: column;
  gap: 1px;
  min-width: 0;
}
.fx-opt-name {
  font-size: 0.74rem;
  font-weight: 700;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* The part of the value the short name does not say. Quiet on purpose: it is
   there to disambiguate two ships called the same thing, not to be read down
   the column. */
.fx-opt-eyebrow {
  font-size: 0.55rem;
  font-weight: 700;
  letter-spacing: 1px;
  text-transform: uppercase;
  color: var(--text-faint);
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* A declared status column, shown as a dot. The two colours are the app's own
   ok/signal tokens rather than a palette this control invents, and the state is
   also in a title attribute — the colour is not the only carrier. */
.fx-opt-dot {
  flex: none;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--text-faint);
}
.fx-opt-dot.is-ok {
  background: var(--ok-green);
}
.fx-opt-dot.is-off {
  background: var(--signal-red);
}

/* ------------------------------------------------------------------ period -- */

.fx-ranges {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
}
.fx-range,
.fx-year,
.fx-month {
  border-radius: var(--radius-sm);
  border: 1.5px solid var(--border);
  background: var(--surface);
  color: var(--text-dim);
  font: inherit;
  font-weight: 600;
  cursor: pointer;
  transition: background var(--transition), border-color var(--transition);
}
.fx-range:hover,
.fx-year:hover,
.fx-month:hover {
  border-color: var(--border-strong);
  color: var(--text);
}
.fx-range {
  padding: 7px 12px;
  font-size: 0.74rem;
}
.fx-years {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 6px;
}
.fx-year {
  padding: 8px 4px;
  font-family: var(--font-data);
  font-size: 0.74rem;
}
.fx-months {
  display: grid;
  grid-template-columns: repeat(6, 1fr);
  gap: 5px;
}
.fx-month {
  padding: 8px 2px;
  border-radius: 7px;
  font-size: 0.7rem;
}
.fx-range[aria-pressed="true"],
.fx-year[aria-pressed="true"] {
  background: var(--brand-navy);
  border-color: var(--brand-navy);
  color: var(--text-on-brand);
}
/* A month is red where a quick range or a year is navy: they are answers to the
   same question, and one of them being the narrowest is worth seeing at a glance. */
.fx-month[aria-pressed="true"] {
  background: var(--signal-red);
  border-color: var(--signal-red);
  color: var(--text-on-brand);
}

.fx-dates {
  display: flex;
  align-items: flex-end;
  gap: 8px;
  flex-wrap: wrap;
}
.fx-dates label {
  display: flex;
  flex-direction: column;
  gap: 3px;
  flex: 1;
  min-width: 0;
}
.fx-dates label span {
  font-size: 0.56rem;
  font-weight: 700;
  letter-spacing: 1.2px;
  text-transform: uppercase;
  color: var(--text-faint);
}
.fx-date {
  width: 100%;
  padding: 6px 9px;
  border: 1.5px solid var(--border);
  border-radius: 7px;
  background: var(--surface);
  color: var(--text);
  font: inherit;
  font-size: 0.74rem;
}
.fx-date:focus {
  outline: none;
  border-color: var(--brand-navy);
}
.fx-dates-arrow {
  padding-bottom: 7px;
  color: var(--text-faint);
}

/* ------------------------------------------------------------------ shared -- */

.chip {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 4px 10px;
  border-radius: 999px;
  background: var(--surface-sunken);
  border: 1px solid var(--border);
  font-size: 0.72rem;
  font-weight: 600;
  color: var(--text-dim);
}
.chip b {
  color: var(--text);
  font-weight: 700;
}

/* One form control, four states — rest, hover, focus, disabled — because a
   control missing one of them reads as unfinished the moment somebody reaches
   it. Focus is a ring rather than the global outline: the outline is for
   elements with no room of their own; a field has a border to build on. */
.field-input,
select.field-input {
  padding: 8px 12px;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface);
  font-size: 0.82rem;
  color: var(--text);
  outline: none;
  transition: border-color var(--transition), box-shadow var(--transition);
  width: 100%;
}
.field-input:hover:not(:disabled):not(:focus) {
  border-color: var(--border-strong);
}
.field-input:focus {
  border-color: var(--brand-navy);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--brand-navy) 14%, transparent);
}
:root[data-theme="dark"] .field-input:focus {
  border-color: var(--text-dim);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--text-dim) 20%, transparent);
}
/* text-dim, not text-faint: a placeholder is copy somebody is expected to
   read, and the faint step fails contrast on the tinted surfaces. */
.field-input::placeholder {
  color: var(--text-dim);
  opacity: 1;
}
.field-input:disabled {
  background: var(--surface-sunken);
  color: var(--text-faint);
  cursor: not-allowed;
}
/* The UA's select chrome is the one part of a form the border tokens cannot
   reach, so the arrow is drawn here — data URI, no external host — and the
   dark theme redraws it in its own dim ink. */
select.field-input {
  appearance: none;
  background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%2364748b' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right 10px center;
  padding-right: 30px;
}
:root[data-theme="dark"] select.field-input {
  background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%239aa7bf' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
}
textarea.field-input {
  resize: vertical;
  min-height: 64px;
  line-height: 1.5;
}

/* Keeping a panel on screen used to be this block's job too — under 900px they
   re-anchored to the right edge, since a left-anchored panel on the last
   trigger runs off-screen. That was both too late (the roster overflows at
   every width) and in the way: `placeOpenPop` sets an inline `left`, and a rule
   setting `right` fights it. The clamp covers both edges now, at all widths.

   The row itself stays one row at every width. It used to wrap here, which
   moved the whole board down by a trigger's height at the moment the window got
   too small to spare it. Instead the triggers shrink: `.fx-trigger-v` already
   ellipsises, so a squeezed trigger loses the tail of its value and keeps its
   label, which is the part that says what the control is.

   Not `overflow-x: auto` on the row — scrolling would need a clipping context,
   and `.fx-pop` hangs out of the row on purpose. Clipping the row clips every
   dropdown in it. */
@media (max-width: 900px) {
  .fx-menu {
    flex-shrink: 1;
    min-width: 0;
  }
  /* Shrinking without clipping is not shrinking: a flex item told it may be
     narrower than its content draws the content anyway, over whatever is beside
     it. That is what put four overlapping filter labels on a phone. Below 700px
     the row stops squeezing altogether (see the responsive section); this is the
     tablet band, where a trimmed value still reads. */
  .fx-trigger {
    min-width: 0;
    overflow: hidden;
  }
  .fx-trigger-txt {
    overflow: hidden;
  }
  .fx-trigger-k,
  .fx-trigger-v {
    overflow: hidden;
    text-overflow: ellipsis;
  }
  .fx-trigger > svg {
    flex: none;
  }
  .fx-trigger-v {
    max-width: none;
  }
  .fx-block {
    grid-template-columns: 1fr;
  }
}

/* ============================================================ dashboard grid */

/* Every section's body, the board included. Shared because a section that was
   narrower or wider than the board would read as a different application. */
.page {
  /* 1440, not the width of the monitor. A quarter of 1600px is 380px of tile
     holding one number, and the eye stops carrying a row across that far. */
  max-width: 1440px;
  margin: 0 auto;
  padding: 24px var(--page-pad) 64px;
}

/* A section that has no screen yet, saying what it will hold. Capped narrower
   than the board because this is prose and the board is not: a sentence set
   across 1440px is a sentence nobody finishes. */
.section-card {
  max-width: 560px;
  /* The tile's own three: the density pad so compact moves this too, and the
     shadow so a card on an otherwise empty page sits at the same height above
     `--bg` as every card the board draws. */
  padding: var(--density-pad);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface);
  box-shadow: var(--shadow-card);
}
.section-card h2 {
  margin: 0 0 6px;
  font-size: 1rem;
  font-weight: 800;
}
.section-card p {
  margin: 0 0 12px;
  line-height: 1.6;
  color: var(--text-dim);
}
.section-card p.section-lead {
  color: var(--text);
  font-weight: 600;
}
.section-card .hint {
  margin: 12px 0 0;
}

.hidden-bar {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-bottom: 14px;
}
.hidden-bar:empty {
  display: none;
}
.hidden-chip {
  padding: 5px 12px;
  border-radius: var(--radius-xs);
  font-size: 0.72rem;
  font-weight: 600;
  cursor: pointer;
  border: 1px dashed var(--border-strong);
  background: var(--surface);
  color: var(--text-dim);
  transition: all var(--transition);
}
.hidden-chip:hover {
  border-style: solid;
  border-color: var(--brand-navy);
  color: var(--text);
}

/* Four columns of `--row-h` rows: a footprint is a quarter, a half or the page,
   one row or two. The column count is set on <html> from the server's MAX_SPAN
   at boot; the fallback here only covers the pre-bootstrap paint. The footprint
   classes stay literal — `grid-column: span` takes no var() arithmetic, which is
   what `test_size_classes_cover_every_declared_size` exists to police.

   `align-items: stretch` — the grid default, restored deliberately. It used to
   be `start`, back when a size was a width only: rows lined up because every
   chart happened to be `--chart-h` tall, and a tile whose content ran long grew
   past its neighbours. Now the row height is the ruler and the tile fills it, so
   two tiles side by side are the same height because the grid says so and not
   because their contents agreed. */
.tile-grid {
  display: grid;
  grid-template-columns: repeat(var(--grid-cols, 4), minmax(0, 1fr));
  grid-auto-rows: var(--row-h);
  gap: var(--grid-gap);
  align-items: stretch;
}

.tile {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: var(--density-pad);
  box-shadow: var(--shadow-card);
  position: relative;
  min-width: 0;
  transition: box-shadow var(--transition), transform var(--transition),
    border-color var(--transition);
  display: flex;
  flex-direction: column;
  /* The row height is now a promise, so content that runs past it is clipped
     rather than allowed to paint over the tile below. Losing the tail of a long
     list is visible and fixable by resizing the tile; a list drawn across its
     neighbour is neither. */
  overflow: hidden;
}
.tile.cols-2 {
  grid-column: span 2;
}
.tile.cols-4 {
  grid-column: span 4;
}
.tile.rows-2 {
  grid-row: span 2;
}

/* KPI tiles carry a status colour as their whole surface. The gradient runs to a
   darker shade of the same hue via colour-mix, so adding a status needs one
   background rule rather than a hand-picked pair of stops. */
.tile[class*="accent-"] {
  border-color: transparent;
  color: rgba(255, 255, 255, 0.92);
}
.tile.accent-critical {
  background: linear-gradient(135deg, var(--signal-red), color-mix(in srgb, var(--signal-red) 62%, #000));
}
.tile.accent-warning {
  background: linear-gradient(135deg, var(--warn-amber), color-mix(in srgb, var(--warn-amber) 62%, #000));
}
.tile.accent-ok {
  background: linear-gradient(135deg, var(--ok-green), color-mix(in srgb, var(--ok-green) 62%, #000));
}
.tile.accent-severe {
  background: linear-gradient(135deg, var(--warn-deep), color-mix(in srgb, var(--warn-deep) 62%, #000));
}
/* Same hue as `accent-brand` below, and deliberately a separate rule rather than
   a shared one. `brand` means "this is the headline"; `info` means "this counts
   open work". They agree today because open is the board's resting state and
   navy is the board's resting colour, and folding them together would make the
   next change to either silently move the other. */
.tile.accent-info {
  background: linear-gradient(135deg, var(--brand-navy), color-mix(in srgb, var(--brand-navy) 62%, #000));
}
.tile.accent-brand {
  background: linear-gradient(135deg, var(--brand-navy), color-mix(in srgb, var(--brand-navy) 55%, #000));
}
.tile[class*="accent-"] .kpi-value,
.tile[class*="accent-"] .kpi-sub,
.tile[class*="accent-"] .kpi-denom,
.tile[class*="accent-"] .kpi-delta {
  color: rgba(255, 255, 255, 0.92);
}
.tile[class*="accent-"] .tile-title,
.tile[class*="accent-"] .kpi-sub {
  color: rgba(255, 255, 255, 0.72);
}
.tile[class*="accent-"] .kpi-icon {
  background: rgba(255, 255, 255, 0.18);
  color: #fff;
}

.tile-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  margin-bottom: 14px;
  min-height: 20px;
}
.tile-title {
  font-size: 0.8rem;
  font-weight: 700;
  color: var(--text-dim);
  letter-spacing: 0.2px;
  display: flex;
  align-items: center;
  gap: 8px;
  min-width: 0;
}
.tile-title .label {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.tile-icon {
  display: grid;
  place-items: center;
  color: var(--signal-red);
  flex: none;
}
.count-pill {
  font-size: 0.62rem;
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: 0.6px;
  padding: 2px 7px;
  border-radius: 999px;
  background: var(--surface-sunken);
  color: var(--text-faint);
  flex: none;
}

/* Which screens draw this tile, said out loud on the tile itself. `.edit-only`,
   like the tile's own buttons: a viewer has no use for it, and on the screen the
   tile *is* drawn on it would be a caption about a rule that is not in force. */
.scope-pill {
  font-size: 0.58rem;
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: 0.7px;
  padding: 2px 7px;
  border-radius: 999px;
  background: var(--surface-sunken);
  color: var(--brand-navy);
  border: 1px dashed var(--border);
  flex: none;
}

/* A tile this screen would not normally draw, shown only while an admin is
   editing the layout. Faded rather than hidden, because the alternative is a
   tile that can only be reached from the screen it is scoped to — and the tile
   menu is how it is edited back. */
.tile.off-device,
.tab.off-device {
  opacity: 0.55;
  border-style: dashed;
}
.tile.off-device:hover,
.tile.off-device:focus-within {
  opacity: 1;
}

.tile-controls {
  display: flex;
  align-items: center;
  gap: 2px;
  flex: none;
}
.tile-btn {
  width: 22px;
  height: 22px;
  display: grid;
  place-items: center;
  border: none;
  background: none;
  border-radius: var(--radius-xs);
  color: var(--text-faint);
  cursor: pointer;
  font-size: 0.7rem;
  opacity: 0;
  transition: all var(--transition);
}
.tile:hover .tile-btn,
.tile:focus-within .tile-btn {
  opacity: 0.7;
}
.tile-btn:hover {
  opacity: 1;
  background: var(--surface-sunken);
  color: var(--text);
}
.tile-btn.danger:hover {
  background: var(--tint-critical);
  color: var(--tint-critical-text);
}
.tile-btn svg {
  width: 13px;
  height: 13px;
}
/* Controls only exist in edit mode; without it the board is read-only. The
   selector is on the class rather than on `.tile-btn.edit-only`, because the
   scope pill wears it too and is not a button — the narrower rule left it drawn
   on every board for everyone. */
body:not(.edit-mode) .edit-only {
  display: none;
}

.tile-body {
  flex: 1;
  min-width: 0;
  /* A column, so the one child that should absorb the tile's spare height can
     say so with `flex: 1` — every widget renders straight into this element, so
     `.chart-area` is a direct child of it and needs no wrapper to reach.
     `min-height: 0` because a flex item's automatic minimum is its content:
     without it the body refuses to be shorter than the chart it holds and the
     tile's fixed row height stops meaning anything. */
  min-height: 0;
  display: flex;
  flex-direction: column;
  /* And the last resort for content the tile clips. A table and a chart each
     bring their own scroll box; prose brings none, and the one tile a fresh
     install ships with is prose — a "wide" note is one row tall and the
     getting-started text is not, so without this the instructions for filling
     the board end mid-sentence for the person who has never seen it work.
     Scrolls only what the flex line above could not fit, which for a widget that
     does size itself to the tile is nothing. */
  overflow: auto;
}

/* --- drag states --- */
body.edit-mode .tile {
  cursor: grab;
}
body.edit-mode .tile:active {
  cursor: grabbing;
}
.tile.dragging {
  opacity: 0.4;
  transform: scale(0.97);
  box-shadow: var(--shadow-pop);
}
.tile.drag-over {
  border-color: var(--brand-navy);
  border-style: dashed;
  background: var(--surface-alt);
}
.drag-handle {
  cursor: grab;
}

.not-filtered {
  color: var(--warn-amber);
  cursor: help;
  font-size: 0.8rem;
  flex: none;
}

/* ===================================================================== KPI == */

/* The card's own box — and, on a count, the click target for the drill into the
   rows behind the number. Its own element rather than `.tile-body` because the
   body outlives each render and `selectable()` on it would stack a listener per
   refresh; flex column so the children lay out exactly as they did when they
   were the body's own. */
.kpi-card {
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
}

.kpi-icon {
  width: 34px;
  height: 34px;
  border-radius: var(--radius-sm);
  display: grid;
  place-items: center;
  background: var(--surface-sunken);
  color: var(--brand-navy);
  margin-bottom: 12px;
}
.kpi-value {
  font-size: 2.2rem;
  font-weight: 800;
  letter-spacing: -1px;
  line-height: 1;
  color: var(--brand-navy);
  font-variant-numeric: tabular-nums;
  font-family: var(--font-data);
}
/* The denominator of a ratio is context, not the figure being read. */
.kpi-denom {
  font-size: 1.2rem;
  font-weight: 700;
  letter-spacing: -0.5px;
  opacity: 0.55;
  margin-left: 2px;
}
.kpi-sub {
  font-size: 0.72rem;
  color: var(--text-faint);
  font-weight: 500;
  margin-top: 8px;
}
.kpi-delta {
  font-size: 0.72rem;
  font-weight: 700;
  margin-top: 6px;
}
.kpi-delta.up {
  color: var(--ok-green);
}
.kpi-delta.down {
  color: var(--signal-red);
}
.kpi-delta.flat {
  color: var(--text-faint);
}
.kpi-trend {
  font-size: 0.74rem;
  font-weight: 700;
  margin-top: 6px;
}
.kpi-trend.up {
  color: var(--ok-green);
}
.kpi-trend.down {
  color: var(--signal-red);
}

/* =================================================================== table == */

/* Flexed into the tile, not merely capped by it. The cap alone was enough while
   a tile grew to fit its contents; now the tile is a fixed number of grid rows
   and clips, so a scroll box taller than the tile puts its last rows — and its
   scrollbar — outside the clip, where nothing can reach them. Two rows of the
   compact density is shorter than this cap, which is exactly where that happened.
   The cap survives for the case the flex line cannot answer: a tile taller than
   any table wants to be. */
.table-scroll {
  overflow: auto;
  flex: 1 1 auto;
  min-height: 0;
  max-height: 420px;
  margin: 0 -4px;
}
table.data {
  width: 100%;
  border-collapse: collapse;
  font-size: var(--density-font);
}
table.data thead th {
  position: sticky;
  top: 0;
  z-index: 1;
  background: var(--surface);
  padding: 8px 10px;
  text-align: left;
  font-size: 0.66rem;
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: 0.6px;
  color: var(--text-dim);
  border-bottom: 2px solid var(--border);
  cursor: pointer;
  white-space: nowrap;
}
table.data thead th:hover {
  color: var(--text);
}
table.data td {
  padding: 0 10px;
  height: var(--density-row);
  border-bottom: 1px solid var(--border);
  white-space: nowrap;
}
table.data tbody tr:hover td {
  background: var(--surface-hover);
}
table.data td.num,
table.data th.num {
  text-align: right;
  font-variant-numeric: tabular-nums;
  font-family: var(--font-data);
}
table.data td:first-child {
  font-weight: 600;
}
.sort-arrow {
  font-size: 0.55rem;
  opacity: 0.5;
  margin-left: 3px;
}

/* status dot always ships with a text label for screen readers */
.status-dot {
  display: inline-block;
  width: 9px;
  height: 9px;
  border-radius: 50%;
  margin-right: 6px;
  vertical-align: middle;
}
.status-ok {
  background: var(--ok-green);
}
.status-warning {
  background: var(--warn-amber);
}
.status-severe {
  background: var(--warn-deep);
}
.status-critical {
  background: var(--signal-red);
}
/* Open, which is a state and not an absence — a finding nobody has closed is
   navy rather than grey, because grey reads as "no data" beside three dots that
   mean something. `status-none` below is the real absence. */
.status-info {
  background: var(--brand-navy);
}
.status-none {
  background: var(--text-faint);
}

.check-mark {
  font-weight: 800;
  font-size: 0.95rem;
  line-height: 1;
}
.check-mark.yes {
  color: var(--ok-green);
}
.check-mark.no {
  color: var(--signal-red);
}

.badge {
  display: inline-block;
  padding: 2px 9px;
  border-radius: 999px;
  font-size: 0.68rem;
  font-weight: 700;
}
.badge-ok {
  background: var(--tint-ok);
  color: var(--tint-ok-text);
}
.badge-warning {
  background: var(--tint-warning);
  color: var(--tint-warning-text);
}
.badge-severe {
  background: var(--tint-severe);
  color: var(--tint-severe-text);
}
.badge-critical {
  background: var(--tint-critical);
  color: var(--tint-critical-text);
}
.badge-info {
  background: var(--tint-info);
  color: var(--tint-info-text);
}

/* ==================================================================== list == */

.list {
  display: flex;
  flex-direction: column;
}
.list-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  height: var(--density-row);
  border-bottom: 1px solid var(--border);
  font-size: var(--density-font);
}
.list-row:last-child {
  border-bottom: none;
}

/* A row that opens the finding it names. `role="button"` and the keyboard
   handler come from `selectable()` in ui.js; this is the half a mouse sees.
   The focus ring is inset because a table row's outline is otherwise clipped by
   the tile's own `overflow: hidden`, leaving keyboard users no visible focus at
   the top and bottom of a scrolled table. */
.row-clickable {
  cursor: pointer;
}
.row-clickable:hover {
  background: var(--surface-hover);
}
.row-clickable:focus-visible {
  outline: 2px solid var(--brand-navy);
  outline-offset: -2px;
}
.list-main {
  font-weight: 600;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.list-meta {
  color: var(--text-dim);
  font-family: var(--font-data);
  flex: none;
}
.list-more {
  padding-top: 10px;
  font-size: 0.72rem;
  color: var(--text-faint);
  font-weight: 600;
}

/* The drill list — the modal a clicked count or bar opens. Rows reuse the list
   widget's own classes on purpose: the modal is the tile it came from, opened
   out, and two row styles would read as two different kinds of record. */
.record-list-count {
  font-size: 0.72rem;
  color: var(--text-faint);
  font-weight: 600;
  margin-bottom: 8px;
}
.record-list-chips {
  display: flex;
  align-items: center;
  gap: 6px;
  flex-wrap: wrap;
  margin-bottom: 12px;
}
.record-list-chips-label {
  font-size: 0.72rem;
  color: var(--text-faint);
  font-weight: 600;
  margin-right: 2px;
}
.record-list-group {
  display: flex;
  align-items: center;
  gap: 8px;
  margin: 14px 0 2px;
  font-size: 0.72rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.4px;
  color: var(--text-dim);
}

/* =================================================================== chart == */

/* The one box every chart is drawn into. `position: relative` so an absolutely
   positioned overlay inside a chart measures against the plot area rather than
   the tile. */
.chart-area {
  position: relative;
  /* Takes whatever the tile's rows left after the head, instead of dictating the
     tile's height the way it used to. `--chart-h` survives as the flex basis —
     the size the box asks for before the row overrules it, which is all it is
     worth once the grid decides heights. `min-height: 0` lets it be overruled
     downwards; without it a `--chart-h` basis is a floor and a small tile grows.
     `height: auto` because a flexed main size and a stated height are two
     answers to one question. */
  flex: 1 1 var(--chart-h);
  min-height: 0;
  height: auto;
  /* A box with nothing clipping it paints over the page. A donut whose key
     wrapped onto a second flex line used to draw it below the tile's bottom
     border, on the background — nothing about the escape looked like a CSS
     problem from the screenshot. */
  overflow: hidden;
}
/* Same job as above, one size down: the shorter ask for a chart that reads badly
   when it is given the whole tile. `flex-basis` and not `height`, or it would be
   a value the flex layout above ignores. */
.chart-area.sm {
  flex-basis: var(--chart-h-sm);
}
/* Content taller than the box scrolls inside it rather than growing the tile —
   a bar chart with a high `Top N` is the case that used to produce a
   thousand-pixel tile. */
.chart-area.scroll {
  overflow-y: auto;
  overflow-x: hidden;
  /* Reserve the gutter always, so the bars keep the same width whether or not
     the box happens to be scrolling — otherwise every bar jumps 6px the moment
     one more category arrives. */
  scrollbar-gutter: stable;
}
/* The axis sits outside the scroll box, so it has to be inset by the same gutter
   the bars are, or the ticks stop lining up with the bar ends. `--scrollbar-w`
   is the width set on ::-webkit-scrollbar above. */
.chart-area.scroll + .hbar-axis {
  margin-top: 10px;
  padding-inline-end: var(--scrollbar-w);
}

/* The line chart: a gutter of tick values, the plot, and the bucket labels under
   the plot only. Four cells of one grid rather than a row above a row, because
   the labels have to be inset by the gutter and nothing here knows how wide the
   gutter is — the widest tick value decides it. */
.line-chart {
  height: 100%;
  display: grid;
  grid-template-columns: auto 1fr;
  /* `minmax(0, 1fr)` and never `1fr`, which is `minmax(auto, 1fr)` — a floor of
     the row's own content. The plot holds an SVG asking for 100% of a box whose
     height it is itself deciding, so `auto` resolves to the image's intrinsic
     size and the row grows past the grid. `.chart-area` clips, and a stretched
     SVG puts the whole line below the cut: tick values on screen, plot blank,
     and every measurement of the tile still correct. That is what it looked
     like, and nothing in the Python suite can see it. */
  grid-template-rows: minmax(0, 1fr) auto;
  column-gap: 8px;
}
/* The 4px is the whole alignment story, and it is duplicated on purpose.

   The SVG is stretched (`preserveAspectRatio: none`), so its viewBox top and
   bottom edges are wherever this padding puts them. Gridlines are drawn at those
   edges, which is why the ticks column pads by the same amount — and subtracts
   half a line so `space-between` lines up the label *centres* with the gridlines
   rather than their top edges. The padding is also the room a 3px dot at the
   maximum needs, since `.chart-area` clips. */
.line-plot {
  padding: 4px 0;
  min-width: 0;
}
.line-plot > svg {
  display: block;
  width: 100%;
  height: 100%;
}
.line-ticks {
  padding: calc(4px - 0.5em) 0;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  align-items: flex-end;
  font-family: var(--font-data);
  font-size: 0.68rem;
  color: var(--text-faint);
  line-height: 1;
}
.line-x {
  display: flex;
  justify-content: space-between;
  padding-top: 8px;
  font-size: 0.72rem;
  font-weight: 600;
  color: var(--text-faint);
}

/* The donut fills its box by putting the key beside the ring rather than under
   it: the ring is square and the box is wide, and the leftover width is exactly
   what a key needs.

   It never wraps. The box height is fixed, so a second flex line has nowhere to
   go — it starts below the box and paints outside the tile. In a one-column tile
   the ring at its natural size plus the key is wider than the tile, so instead
   the ring shrinks: it keeps its viewBox, which means a smaller circle rather
   than a squashed one, and the key keeps its place beside it. */
.donut-chart {
  display: flex;
  align-items: center;
  gap: 16px;
  flex-wrap: nowrap;
}
.donut-ring {
  height: 100%;
  aspect-ratio: 1;
  /* Shrinkable — `flex: none` is what forced the wrap. */
  flex: 0 1 auto;
  min-width: 0;
}
.donut-legend {
  flex: 1 1 120px;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 6px;
  /* More slices than fit are reachable rather than clipped; the ring beside it
     still shows every one of them. */
  max-height: 100%;
  overflow-y: auto;
}
.donut-legend-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  font-size: var(--density-font);
}
.donut-legend-name {
  display: flex;
  align-items: center;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.donut-legend-pct {
  color: var(--text-dim);
  font-variant-numeric: tabular-nums;
  flex: none;
}

/* Bars are drawn in CSS rather than canvas — crisper at small sizes and they
   inherit theme tokens for free. */
.hbar {
  display: flex;
  flex-direction: column;
  gap: 10px;
}
.hbar-row {
  display: grid;
  grid-template-columns: minmax(80px, 34%) 1fr auto;
  align-items: center;
  gap: 10px;
  font-size: var(--density-font);
}
.hbar-label {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-weight: 600;
}
.hbar-track {
  background: var(--surface-sunken);
  border-radius: 999px;
  height: 10px;
  overflow: hidden;
}
.hbar-fill {
  height: 100%;
  border-radius: 999px;
  background: var(--brand-navy);
  transition: width 300ms ease;
}
.hbar-fill.ok {
  background: var(--ok-green);
}
.hbar-fill.warning {
  background: var(--warn-amber);
}
.hbar-fill.severe {
  background: var(--warn-deep);
}
.hbar-fill.critical {
  background: var(--signal-red);
}
.hbar-fill.info {
  background: var(--brand-navy);
}
/* A stacked bar is the same track with several segments laid side by side. Each
   segment's width is its share of the axis top, not of the bar, so a short bar
   stays short — the axis means the same thing in both modes. */
.hbar-stack {
  display: flex;
}
.hbar-seg {
  height: 100%;
  transition: width 300ms ease;
}
.hbar-seg:first-child {
  border-start-start-radius: 999px;
  border-end-start-radius: 999px;
}
.hbar-seg:last-child {
  border-start-end-radius: 999px;
  border-end-end-radius: 999px;
}
.hbar-value {
  font-family: var(--font-data);
  font-weight: 700;
  font-variant-numeric: tabular-nums;
}

.chart-legend {
  display: flex;
  flex-wrap: wrap;
  gap: 4px 14px;
  margin-top: 8px;
  font-size: 0.7rem;
  color: var(--text-dim);
}
.legend-item {
  display: inline-flex;
  align-items: center;
  gap: 5px;
}
.legend-swatch {
  width: 9px;
  height: 9px;
  border-radius: 2px;
  flex: none;
}
.chart-note {
  margin-top: 8px;
  font-size: 0.7rem;
  color: var(--warn-amber);
}

.hbar-axis {
  margin-top: 2px;
  border-top: 1px solid var(--border);
  padding-top: 5px;
}
/* space-between puts the first tick on the track's left edge and the last on its
   right, so a tick sits exactly where a bar of that value would end. */
.hbar-ticks {
  display: flex;
  justify-content: space-between;
  font-size: 0.65rem;
  color: var(--text-faint);
  font-family: var(--font-data);
  font-variant-numeric: tabular-nums;
}

/* ================================================= markdown / empty / skeleton */

.md {
  font-size: 0.85rem;
  line-height: 1.6;
}
.md h1,
.md h2,
.md h3 {
  font-size: 0.95rem;
  margin: 12px 0 6px;
}
.md p {
  margin-bottom: 8px;
}
.md ul {
  margin: 0 0 8px 18px;
}
.md code {
  font-family: var(--font-data);
  background: var(--surface-sunken);
  padding: 1px 5px;
  border-radius: 4px;
  font-size: 0.8em;
}
.md a {
  color: var(--brand-navy);
}
:root[data-theme="dark"] .md a {
  color: var(--tint-info-text);
}

.empty-state {
  padding: 22px 12px;
  text-align: center;
  color: var(--text-dim);
  font-size: 0.82rem;
  line-height: 1.6;
}
/* A tile that declared its own empty sentence is answering, not apologising —
   see `emptyState` in `widgets/rules.js`. Large enough to read as the content of
   the tile rather than as a caption under an absent chart, and centred in the
   space the chart would have filled, because a scoped tile with nothing in it is
   usually the best news on the board. The colour is inline, from the declared
   style's own token. */
.empty-declared {
  font-size: 1.15rem;
  font-weight: 650;
  letter-spacing: -0.01em;
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 100%;
  padding: 12px;
}

.empty-state code {
  font-family: var(--font-data);
  font-size: 0.75rem;
  background: var(--surface-sunken);
  padding: 2px 6px;
  border-radius: 4px;
  display: inline-block;
  margin-top: 6px;
  word-break: break-all;
}

/* The grid's own empty states. `align-self: start` because there is no tile to
   line up with and a stretched one would be a --row-h box of whitespace; the
   outline because on an empty tab this card carries the only two controls
   there are. */
.tile-grid > .empty-state {
  align-self: start;
  padding: 32px 16px;
  border: 1px dashed var(--border);
  border-radius: var(--radius);
}

.error-state {
  padding: 16px 12px;
  text-align: center;
  color: var(--tint-critical-text);
  background: var(--tint-critical);
  border-radius: var(--radius-sm);
  font-size: 0.78rem;
}

/* Shimmer placeholders instead of spinners — the tile keeps its final shape. */
.skeleton {
  background: linear-gradient(
    90deg,
    var(--surface-sunken) 25%,
    var(--surface-alt) 37%,
    var(--surface-sunken) 63%
  );
  background-size: 400% 100%;
  animation: shimmer 1.3s ease infinite;
  border-radius: var(--radius-xs);
}
.skeleton-line {
  height: 12px;
  margin-bottom: 10px;
}
.skeleton-value {
  height: 34px;
  width: 60%;
  margin-bottom: 10px;
}
@keyframes shimmer {
  0% {
    background-position: 100% 50%;
  }
  100% {
    background-position: 0 50%;
  }
}
@media (prefers-reduced-motion: reduce) {
  .skeleton {
    animation: none;
  }
  * {
    transition-duration: 1ms !important;
  }
}

/* ================================================================= buttons == */

.btn {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 8px 16px;
  border-radius: var(--radius-sm);
  border: 1px solid var(--border);
  background: var(--surface);
  color: var(--text);
  font-size: 0.8rem;
  font-weight: 600;
  cursor: pointer;
  transition: all var(--transition);
}
.btn:hover {
  background: var(--surface-hover);
  border-color: var(--border-strong);
}
.btn.primary {
  background: var(--brand-navy);
  border-color: var(--brand-navy);
  color: var(--text-on-brand);
}
.btn.primary:hover {
  filter: brightness(1.15);
}
.btn.danger {
  background: var(--surface);
  border-color: var(--signal-red);
  color: var(--signal-red);
}
.btn.danger:hover {
  background: var(--tint-critical);
}
.btn.sm {
  padding: 5px 11px;
  font-size: 0.74rem;
}
.btn:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}
/* The reorder arrows on a tab-widgets row. Square and 32px because on a phone
   this is the only way to reorder a tab at all — drag is dragstart/drop and
   fires on no touchscreen — and a target smaller than a thumb is not a way. */
.btn.icon {
  padding: 0;
  width: 32px;
  min-width: 32px;
  height: 32px;
  justify-content: center;
  line-height: 1;
}
.btn svg {
  width: 14px;
  height: 14px;
}

/* ================================================================== modals == */

.overlay {
  position: fixed;
  inset: 0;
  background: rgba(8, 12, 22, 0.5);
  backdrop-filter: blur(2px);
  z-index: 300;
  display: none;
  align-items: center;
  justify-content: center;
  padding: 20px;
}
.overlay.open {
  display: flex;
}
.overlay.top {
  z-index: 400;
}
body.booting .overlay {
  background: var(--surface);
  backdrop-filter: none;
}

.modal {
  background: var(--surface);
  border-radius: var(--radius);
  width: 520px;
  max-width: 100%;
  max-height: 90vh;
  box-shadow: var(--shadow-pop);
  display: flex;
  flex-direction: column;
  overflow: hidden;
}
.modal.wide {
  width: 860px;
}
.modal-header {
  padding: 18px 22px;
  border-bottom: 1px solid var(--border);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  flex: none;
}
.modal-header h2 {
  font-size: 1rem;
  font-weight: 800;
  color: var(--text);
}
.modal-close {
  width: 28px;
  height: 28px;
  border-radius: var(--radius-xs);
  border: none;
  background: var(--surface-sunken);
  cursor: pointer;
  display: grid;
  place-items: center;
  font-size: 1.1rem;
  color: var(--text-dim);
}
.modal-close:hover {
  background: var(--border);
  color: var(--text);
}
.modal-body {
  padding: 22px;
  overflow-y: auto;
  flex: 1;
  min-height: 0;
}
/* One finding, opened from anywhere it is drawn. A two-column definition list
   rather than a table: there is one record here, so a header row would be a
   heading over a single value, and label-beside-value is what a person reads
   when they are checking one field they already have in mind. It collapses to
   one column on a phone, where 40% of a narrow screen spent on labels leaves the
   values unreadable. */
.record-detail {
  display: grid;
  grid-template-columns: minmax(120px, 30%) 1fr;
  gap: 8px 16px;
  margin: 0;
  font-size: var(--density-font);
}
.record-detail dt {
  color: var(--text-dim);
  font-weight: 600;
}
.record-detail dd {
  margin: 0;
  overflow-wrap: anywhere;
  font-weight: 500;
}
.record-detail dd.is-empty {
  color: var(--text-faint);
  font-weight: 400;
}
/* Machine references — GUIDs, URLs — in the data face and dimmed, so the eye
   can skip what it was never going to read. They stay selectable text: the one
   thing anyone does with a GUID is copy it. */
.record-detail dd.is-ref {
  font-family: var(--font-data);
  font-size: 0.92em;
  color: var(--text-dim);
  font-weight: 400;
}
/* Prose-length values take the whole row: label above, paragraph below, capped
   at a readable measure. Squeezed into the value column they wrap into a ribbon
   three words wide beside an empty label gutter. */
.record-detail dt.is-long,
.record-detail dd.is-long {
  grid-column: 1 / -1;
}
.record-detail dd.is-long {
  max-width: 65ch;
  line-height: 1.55;
  margin-bottom: 2px;
}
/* The rest of the payload, one click away. Native disclosure — the affordance
   everyone already knows — with the summary styled as the app's own small
   label, not a control invented for this modal. */
.record-detail-more {
  margin-top: 16px;
  border-top: 1px solid var(--border);
}
.record-detail-more summary {
  cursor: pointer;
  padding: 10px 0;
  font-size: 0.72rem;
  font-weight: 700;
  color: var(--text-dim);
  user-select: none;
}
.record-detail-more summary:hover {
  color: var(--text);
}
.record-detail-more[open] summary {
  padding-bottom: 12px;
}
.record-detail-state {
  margin-bottom: 14px;
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
}
.record-detail-state:empty {
  display: none;
}
.record-detail-due {
  font-size: 0.76rem;
  font-weight: 600;
  color: var(--text-dim);
}
@media (max-width: 700px) {
  .record-detail {
    grid-template-columns: 1fr;
    gap: 2px;
  }
  .record-detail dd {
    margin-bottom: 10px;
  }
  .record-detail dd.is-long {
    max-width: none;
  }
}

.modal-footer {
  padding: 14px 22px;
  border-top: 1px solid var(--border);
  display: flex;
  justify-content: flex-end;
  gap: 8px;
  flex: none;
}

.field {
  margin-bottom: 16px;
}
.field label {
  display: block;
  font-size: 0.72rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.5px;
  color: var(--text-dim);
  margin-bottom: 6px;
}
/* Scoped to .field for a long time, which quietly meant every bare `class:
   "hint"` outside a form field — the admin screens and the presets list — was
   rendering at full body size in body colour. The look is the same everywhere;
   only the spacing is specific to sitting under a label. */
.hint {
  font-size: 0.7rem;
  color: var(--text-faint);
  line-height: 1.5;
}
.field .hint {
  margin-top: 5px;
}
.field-error {
  font-size: 0.75rem;
  color: var(--signal-red);
  margin-top: 6px;
  min-height: 1em;
}
.field-row {
  display: flex;
  gap: 12px;
}
.field-row > * {
  flex: 1;
}
/* A scrollable set of checkboxes inside a form field — dataset scope on the
   filter editor. Capped rather than unbounded: a fleet's worth of datasets would
   otherwise push the Save button off the modal. */
.checkbox-list {
  display: flex;
  flex-direction: column;
  gap: 2px;
  max-height: 140px;
  overflow-y: auto;
}
.filter-form {
  margin-top: 6px;
}
.checkbox-row {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 0.82rem;
  font-weight: 500;
}
.checkbox-row input {
  width: 16px;
  height: 16px;
  accent-color: var(--brand-navy);
}

.section-title {
  font-size: 0.7rem;
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: 0.7px;
  color: var(--text-faint);
  margin: 22px 0 10px;
}
.section-title:first-child {
  margin-top: 0;
}

/* The add-widget picker: ready-made tiles grouped by the case they answer, with
   the raw types as the last group. `auto-fill` rather than a fixed column count
   because the modal is the same one on a phone, where two cards side by side are
   two truncated titles. */
.catalogue-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
  gap: 8px;
}
.catalogue-card {
  display: flex;
  flex-direction: column;
  gap: 4px;
  padding: 10px 12px;
  text-align: left;
  border: 1px solid var(--border);
  border-radius: 10px;
  background: var(--surface);
  cursor: pointer;
}
.catalogue-card:hover,
.catalogue-card:focus-visible {
  border-color: var(--brand-navy);
  background: var(--surface-hover);
}
.catalogue-title {
  font-size: 0.85rem;
  font-weight: 700;
  color: var(--text);
}
.catalogue-meta {
  font-size: 0.72rem;
  color: var(--text-faint);
}

/* ========================================================== saved views === */

.preset-list {
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.preset-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  flex-wrap: wrap;
  padding: 10px 12px;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface);
}
.preset-meta {
  min-width: 0;
}
.preset-name {
  font-weight: 700;
  font-size: 0.86rem;
}
.preset-row .actions {
  display: flex;
  gap: 6px;
  flex: none;
}

/* ================================================================== admin === */

.admin-table {
  width: 100%;
  border-collapse: collapse;
  font-size: 0.82rem;
}
.admin-table th {
  text-align: left;
  padding: 8px;
  font-size: 0.66rem;
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: 0.5px;
  color: var(--text-dim);
  border-bottom: 2px solid var(--border);
}
.admin-table td {
  padding: 9px 8px;
  border-bottom: 1px solid var(--border);
  vertical-align: middle;
}
.admin-table td.actions {
  text-align: right;
  white-space: nowrap;
}
.mono {
  font-family: var(--font-data);
  font-size: 0.76rem;
}
.key-reveal {
  background: var(--tint-warning);
  color: var(--tint-warning-text);
  border-radius: var(--radius-sm);
  padding: 12px 14px;
  font-size: 0.78rem;
  line-height: 1.6;
  margin-bottom: 16px;
}
.key-reveal code {
  display: block;
  font-family: var(--font-data);
  background: var(--surface);
  color: var(--text);
  padding: 8px 10px;
  border-radius: var(--radius-xs);
  margin-top: 8px;
  word-break: break-all;
  font-size: 0.76rem;
}

/* --- locations ---------------------------------------------------------- */

.sync-status {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 14px;
  flex-wrap: wrap;
  padding: 12px 14px;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface-sunken);
  margin-bottom: 16px;
}
.sync-status-main {
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 4px;
}
.sync-status-line {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 0.82rem;
  font-weight: 600;
}

/* Shared control row: search + toggles + a navigation button. */
.admin-toolbar {
  display: flex;
  gap: 8px;
  align-items: center;
  flex-wrap: wrap;
  margin: 12px 0;
}
.admin-toolbar input[type="search"] {
  flex: 1;
  min-width: 180px;
}

/* An input inside a table cell or a list row. `.field-input` is width:100%,
   which in a four-column table would push everything else off the edge. */
.cell-input {
  width: 140px;
  padding: 5px 8px;
  font-size: 0.78rem;
}
.cell-input.saving {
  opacity: 0.55;
}
.cell-input.invalid {
  border-color: var(--signal-red);
}
.preset-meta .cell-input {
  width: 220px;
}

.chip-row {
  display: flex;
  gap: 6px;
  flex-wrap: wrap;
  align-items: center;
}

/* Confirmation that an inline edit landed, since these cells deliberately do
   not trigger a repaint. Animating to `transparent` rather than to a literal
   colour is what keeps it correct in both themes. */
.row-saved {
  animation: row-flash 900ms ease-out;
}
@keyframes row-flash {
  from {
    background: var(--tint-ok);
  }
  to {
    background: transparent;
  }
}

/* Present locally, absent from the last sync. */
.row-inactive {
  opacity: 0.6;
}

/* ================================================================= tweaks === */

.swatch-row {
  display: flex;
  gap: 8px;
}
.swatch {
  width: 34px;
  height: 34px;
  border-radius: var(--radius-sm);
  border: 2px solid transparent;
  cursor: pointer;
  padding: 0;
  transition: transform var(--transition);
}
.swatch:hover {
  transform: scale(1.08);
}
.swatch[aria-pressed="true"] {
  border-color: var(--text);
  box-shadow: var(--ring);
}

.seg {
  display: inline-flex;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  overflow: hidden;
}
.seg button {
  padding: 7px 16px;
  border: none;
  background: var(--surface);
  color: var(--text-dim);
  font-size: 0.78rem;
  font-weight: 600;
  cursor: pointer;
}
.seg button + button {
  border-left: 1px solid var(--border);
}
.seg button[aria-pressed="true"] {
  background: var(--brand-navy);
  color: var(--text-on-brand);
}

/* ================================================================= toasts === */

.toasts {
  position: fixed;
  bottom: 20px;
  right: 20px;
  z-index: 500;
  display: flex;
  flex-direction: column;
  gap: 8px;
  max-width: min(420px, calc(100vw - 40px));
}
.toast {
  background: var(--surface);
  border: 1px solid var(--border);
  border-left: 4px solid var(--brand-navy);
  border-radius: var(--radius-sm);
  box-shadow: var(--shadow-pop);
  padding: 12px 14px;
  font-size: 0.8rem;
  display: flex;
  align-items: flex-start;
  gap: 10px;
  animation: slide-in 200ms ease;
}
.toast.error {
  border-left-color: var(--signal-red);
}
.toast.success {
  border-left-color: var(--ok-green);
}
.toast-msg {
  flex: 1;
  line-height: 1.5;
}
.toast button {
  border: none;
  background: none;
  cursor: pointer;
  color: var(--text-faint);
  font-size: 1rem;
  line-height: 1;
}
@keyframes slide-in {
  from {
    opacity: 0;
    transform: translateY(8px);
  }
  to {
    opacity: 1;
    transform: none;
  }
}

/* ============================================================== responsive == */

/* Two steps, 4 → 2 → 1, each clamping every width to the new column count so a
   wide tile degrades to full width rather than overflowing the grid. Halving is
   why four columns is worth having: a `cols-2` tile is half the board at every
   width above 700px, where under five columns it was two fifths, then two
   thirds, then everything. `cols-2` is not clamped at 1100px because two of two
   columns is already full width.

   Columns only — `rows-2` is deliberately left alone at both steps. A phone
   narrows the board, it does not shorten the tiles: a table that needed two rows
   of height to be worth reading still needs them at full width, and clamping the
   height as well would hand every narrow screen a page of half-cropped tiles. */
@media (max-width: 1100px) {
  :root {
    --page-pad: 20px;
  }
  .tile-grid {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
  .tile.cols-4 {
    grid-column: span 2;
  }
}

/* The phone band. 700px and the 1100px above it are the two numbers `store.js`
   restates in `SCREEN_QUERIES` to decide whether a tile or a tab scoped to one
   screen is drawn, and `test_the_board_reflows_where_the_screens_say_it_does`
   fails if the two lists drift in either direction — a board already in one
   column while the script still calls it a desktop is a tile that never
   disappears and a tile that never appears, and a band the script cannot see at
   all is a screen nobody can scope to.

   What changes here is not only sizes. The filter bar swaps shape (one trigger,
   see `renderBar`) and its panels stop being dropdowns, because a 1440px roster
   anchored to a trigger has nowhere to go on a 390px screen. */
@media (max-width: 700px) {
  .tile-grid {
    grid-template-columns: minmax(0, 1fr);
  }
  .tile.cols-2,
  .tile.cols-4 {
    grid-column: span 1;
  }
  .kpi-value {
    font-size: 1.8rem;
  }
  .header-subtitle,
  .updated-chip {
    display: none;
  }
  .brand-logo {
    height: 28px;
  }

  /* --- header ---------------------------------------------------------- */

  /* The title gives way before the controls do: it ellipsises (see
     `.header-title h1`) and it is the only thing here that can afford to. Every
     button in `.header-right` is a different action and none of them has a
     second route. */
  .header {
    padding-top: 12px;
  }
  .header-top {
    gap: 10px;
    padding-bottom: 12px;
  }
  .header-brand {
    gap: 10px;
  }
  .header-title h1 {
    font-size: 1.05rem;
    letter-spacing: 0.3px;
  }
  .header-right {
    gap: 6px;
  }
  .hbtn.round {
    width: 30px;
    height: 30px;
  }
  .avatar {
    width: 30px;
    height: 30px;
  }
  .tabbar {
    gap: 18px;
  }

  /* --- filter bar ------------------------------------------------------ */

  /* One trigger, and it takes the room the four used to. The fold button goes
     with them: folding a row that is a single button is hiding the filters. */
  .fx-toggle {
    display: none;
  }
  .fx-bar {
    min-height: 0;
    gap: 8px;
  }
  .fx-triggers,
  .fx-menu {
    flex: 1;
    min-width: 0;
  }
  .fx-trigger {
    width: 100%;
    overflow: hidden;
  }
  .fx-trigger-txt {
    overflow: hidden;
  }
  .fx-trigger-v {
    max-width: none;
    overflow: hidden;
    text-overflow: ellipsis;
  }
  .fx-caret {
    margin-left: auto;
  }
  /* The spacer's whole job is to push Views to the far end of a wide bar. Here
     there is no far end to push to, and a flexible gap between two controls that
     should sit next to each other only starves the trigger. */
  .fx-spacer {
    display: none;
  }
  /* "Showing all" costs a third of the bar to say what the trigger beside it
     already says — it reads "All" when nothing is filtered, and the chip row
     says what is when something is. The folded strip is a different sentence
     and is not shown here anyway: the bar does not fold on a phone. */
  .fx-empty {
    display: none;
  }

  /* Active filters scroll sideways rather than wrapping: the bar is sticky, and
     a second and third row of chips appearing as somebody selects ships would
     push the board down the screen mid-selection. */
  .fx-chiprow {
    overflow-x: auto;
    scrollbar-width: none;
  }
  .fx-chiprow::-webkit-scrollbar {
    display: none;
  }
  .fx-chips {
    flex-wrap: nowrap;
  }
  .fx-chip,
  .fx-clear,
  .fx-chiprow-k {
    flex: none;
  }

  /* A sheet across the bottom, not a dropdown. Anchoring to the trigger is what
     says which control is open, and with one trigger there is nothing to say —
     what matters instead is that the panel is reachable by thumb and that the
     list inside it, not the page, is what scrolls. `placeOpenPop` leaves the
     inline `left`/`max-width` off at this width, so nothing fights these. */
  .fx-pop {
    position: fixed;
    top: auto;
    left: 0;
    right: 0;
    bottom: 0;
    width: auto;
    min-width: 0;
    max-height: 85vh;
    border-radius: var(--radius) var(--radius) 0 0;
    padding: 14px var(--page-pad) calc(14px + env(safe-area-inset-bottom, 0px));
    animation: fx-rise 0.2s cubic-bezier(0.4, 0, 0.2, 1);
  }
  /* The roster panel sizes itself to lay classes out side by side. There is no
     side by side here — one column of groups, scrolled. */
  .fx-pop-wide {
    width: auto;
    max-width: none;
  }
  .fx-opt-grid,
  .fx-opt-grid-wide {
    grid-template-columns: minmax(0, 1fr);
  }
  /* Done is the way out of a sheet, so it stays on screen while the body under
     it scrolls — the footer is already `flex-shrink: 0`, this makes the target
     worth aiming at. */
  .fx-done {
    padding: 12px 24px;
  }
}

@keyframes fx-rise {
  from {
    opacity: 0;
    transform: translateY(16px);
  }
}

/* =============================================================== scheduler == */
/* The Scheduler section: toolbar, Gantt, masters table, editor. Colours are the
   same status tokens the board's bars use (`.hbar-fill.*`), so the Gantt is the
   legend the rest of the app already taught. The section is desktop-only —
   the rail that reaches it is — so this block declares no breakpoints. */

.sched-page {
  display: flex;
  flex-direction: column;
  gap: 14px;
}
.sched-toolbar {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 8px;
}
/* The controls wrap inside their own group, so a narrow window folds the
   filters onto a second line while Sync and New stay top-right. */
.sched-toolbar-group {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-wrap: wrap;
  min-width: 0;
}
.sched-toolbar-actions {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-shrink: 0;
}
/* Toolbar controls are `.field-input` in its compact form: width from the
   value rather than the form's 100%, and padding trimmed to sit level with
   `.btn.sm` in the same row. */
/* max-width, because a native select is as wide as its widest option and the
   vessel list holds full ship names; the closed control clips the tail of a
   long selection rather than pushing the row into a wrap. */
.sched-toolbar .field-input {
  width: auto;
  max-width: 170px;
  padding: 4px 26px 4px 10px;
  font-size: 0.78rem;
  background-position: right 8px center;
}
/* .section-card's 560px cap is for settings-shaped cards; these three views
   are the page's whole working surface and use all of it. */
.sched-page .section-card {
  max-width: none;
}
/* The seg control names the view; the card states only its count. */
.sched-count {
  margin: 0 0 8px;
}
/* Item titles are machine-built identifiers — base_SHORTNAME_date — and the
   data font is what makes the three parts scannable as parts. */
.sched-item-title {
  font-family: var(--font-data);
  font-size: 0.76rem;
  font-weight: 600;
  max-width: 360px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.sched-item-date {
  white-space: nowrap;
  color: var(--text-dim);
}
.sched-editor {
  display: flex;
  flex-direction: column;
  gap: 12px;
}
.sched-editor-grid {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  gap: 12px;
  align-items: start;
}
.sched-editor-grid-3 {
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr);
}
/* A section head groups the form the way the eye reads it: what, when, where.
   Border-top rather than a box, so the modal stays one surface. */
.sched-section-head {
  display: flex;
  align-items: baseline;
  gap: 10px;
  border-top: 1px solid var(--border);
  padding-top: 12px;
  margin-top: 4px;
}
.sched-section-head h3 {
  font-size: 0.72rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-dim);
  margin: 0;
}
/* The one rule of this form nobody guesses — the item naming — shown as a live
   example rather than explained in prose. */
.sched-title-preview {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 0.72rem;
  color: var(--text-dim);
  background: color-mix(in srgb, var(--border) 30%, transparent);
  border-radius: 6px;
  padding: 6px 10px;
}
.sched-vessel-tools {
  display: flex;
  align-items: center;
  gap: 8px;
}
.sched-vessel-grid {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  gap: 2px 16px;
  max-height: 240px;
  overflow-y: auto;
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 8px 10px;
}
.sched-vessel {
  min-width: 0;
  padding: 4px 6px;
  border-radius: 6px;
  cursor: pointer;
  transition: background-color var(--transition);
}
.sched-vessel:not(.sched-vessel-disabled):hover {
  background: var(--surface-sunken);
}
.sched-vessel-code {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 0.72rem;
  font-weight: 700;
  min-width: 34px;
}
.sched-vessel-name {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-weight: 500;
}
.sched-vessel-disabled {
  opacity: 0.55;
  cursor: not-allowed;
}
.sched-vessel-why {
  font-size: 0.68rem;
  color: var(--warn-deep);
  white-space: nowrap;
}
.sched-detail {
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.sched-detail-status {
  margin-bottom: 4px;
}
/* Cancelled: deliberately no hue — it is the one state that asks for nothing. */
.badge-none {
  background: var(--tint-neutral, rgba(127, 127, 127, 0.15));
  color: var(--text-dim);
}

/* -- gantt ------------------------------------------------------------------ */

.gantt {
  display: flex;
  flex-direction: column;
  gap: 2px;
}
.gantt-axis,
.gantt-row {
  display: grid;
  /* minmax(0, 1fr), never a bare 1fr: the track holds absolutely-placed bars
     and must be allowed to shrink below its content. */
  grid-template-columns: 180px minmax(0, 1fr);
  gap: 8px;
  align-items: center;
}
/* A vessel's header spans the whole strip and rules it off from the one
   above, so the eye can find a ship without counting rows. The count sits on
   the right where every other card keeps its numbers. */
.gantt-group-label {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 10px;
  font-size: 0.78rem;
  font-weight: 700;
  margin-top: 14px;
  padding-bottom: 4px;
  border-bottom: 1px solid var(--border);
}
.gantt-group-label:first-of-type {
  margin-top: 4px;
}
.gantt-group-count {
  font-size: 0.68rem;
  font-weight: 600;
  color: var(--text-faint);
}
.gantt-row-label {
  font-size: 0.75rem;
  color: var(--text-dim);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.gantt-track {
  position: relative;
  height: 26px;
  border-radius: 6px;
  background: color-mix(in srgb, var(--border) 35%, transparent);
  overflow: hidden;
}
.gantt-axis-track {
  height: 18px;
  background: none;
  overflow: visible;
}
.gantt-tick {
  position: absolute;
  top: 0;
  font-size: 0.65rem;
  color: var(--text-faint);
  transform: translateX(2px);
}
.gantt-gridline {
  position: absolute;
  top: 0;
  bottom: 0;
  width: 1px;
  background: var(--border);
  opacity: 0.6;
}
.gantt-today {
  position: absolute;
  top: 0;
  bottom: 0;
  width: 2px;
  background: var(--signal-red);
  z-index: 2;
}
/* A bar is a real button: click, Enter and Space all open the item, and the
   visually-hidden span inside carries the dates a colour cannot. The focus
   ring is inset for the reason .row-clickable's is — the track clips at its
   own edge, and an outline drawn outside the bar is an outline nobody sees. */
.gantt-bar {
  position: absolute;
  top: 4px;
  bottom: 4px;
  display: flex;
  align-items: center;
  overflow: hidden;
  border: none;
  border-radius: 4px;
  padding: 0 6px;
  cursor: pointer;
  background: var(--brand-navy);
  z-index: 1;
  box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.12);
  transition: filter var(--transition), box-shadow var(--transition);
}
.gantt-bar:hover {
  filter: brightness(1.12);
  box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.12), 0 1px 4px rgba(0, 0, 0, 0.25);
}
.gantt-bar:focus-visible {
  outline: 2px solid var(--text);
  outline-offset: -2px;
}
/* The in-bar date is decorative (the hidden span holds the real one) but it
   still has to be read: white on the two light fills — amber and green —
   washes out, so those two take ink instead. */
.gantt-bar-label {
  font-size: 0.64rem;
  font-weight: 700;
  color: #fff;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  pointer-events: none;
}
.gantt-bar.warning .gantt-bar-label,
.gantt-bar.ok .gantt-bar-label {
  color: rgba(10, 16, 28, 0.78);
}
.gantt-bar.none .gantt-bar-label {
  color: rgba(10, 16, 28, 0.7);
}
.gantt-bar.ok {
  background: var(--ok-green);
}
.gantt-bar.warning {
  background: var(--warn-amber);
}
.gantt-bar.severe {
  background: var(--warn-deep);
}
.gantt-bar.critical {
  background: var(--signal-red);
}
.gantt-bar.info {
  background: var(--brand-navy);
}
/* Brand navy is nearly the dark theme's own surface, so a planned bar drawn
   in it vanishes into the track. Mixed from the info tint pair rather than a
   new literal, so a repaint of the palette carries the bars with it. */
:root[data-theme="dark"] .gantt-bar.info {
  background: color-mix(in srgb, var(--tint-info-text) 40%, var(--tint-info));
}
.gantt-bar.none {
  background: var(--text-faint);
  opacity: 0.55;
}
