/* The hub's operator console: a dense dark page read at a glance from
   across a bench. Colour marks state only (good, warn, bad, info, idle),
   never a neutral fact such as a set flag or a zero count, and every
   coloured mark carries its text too. Only an abnormal state (warn, bad)
   is filled; a normal one is plain text, and the tally is the one loud
   element. No web fonts: the hub serves the page offline. */

:root {
  --ground: #1a1f27;
  --panel: #212833;
  --raised: #2a323f;
  --rule: #353e4c;
  --ink: #dfe4ea;
  /* Text on the selected row's --raised keeps 4.5:1 (WCAG 1.4.3). */
  --muted: #97a1b0;
  --good: #6cc28f;
  --warn: #e0b04c;
  --bad: #f58b80;
  --info: #79aee8;
  --idle: #a0a9b7;
  --select: #7fd1c9;
  /* The map's position language (src/view/map/visual.rs). One meaning per
     hue: where a position came from. --pos-fault is red, for a fault and
     nothing else; a shape or a glyph always goes with it. Read by the canvas
     through getComputedStyle, so they stay plain hex. */
  --pos-own: var(--info);
  --pos-rtk: var(--good);
  --pos-manual: #d08fc6;
  --pos-estimating: var(--idle);
  --pos-none: var(--muted);
  --pos-fault: var(--bad);
  --pos-caution: var(--warn);
  --pos-neutral: #b8c1cd;
  --pos-halo: #11151b;
  --pos-ink: #f2f5f9;
  --sans: "IBM Plex Sans", "Source Sans 3", "Segoe UI", system-ui, sans-serif;
  --mono: "JetBrains Mono", "IBM Plex Mono", ui-monospace, "SFMono-Regular", Menlo, Consolas,
    "Liberation Mono", monospace;
  color-scheme: dark;
}

/* A light theme, for the map alone (owner, 2026-11-12): the basemap is
   light, so the picture over it and the controls on it take these, while
   the page around stays dark. The position language's tokens are said
   again here: a token that reads another keeps the value it had on :root. */
:root[data-theme="light"],
.theme-light {
  --ground: #f4f6f9;
  --panel: #ffffff;
  --raised: #e9edf2;
  --rule: #cfd6df;
  --ink: #1b2330;
  --muted: #586374;
  --good: #137a54;
  --warn: #8a5f00;
  --bad: #c0301f;
  --info: #1f66b4;
  --idle: #626c7a;
  --select: #0d7f75;
  --pos-manual: #9c3a8a;
  --pos-neutral: #4f5a69;
  --pos-own: var(--info);
  --pos-rtk: var(--good);
  --pos-estimating: var(--idle);
  --pos-none: var(--muted);
  --pos-fault: var(--bad);
  --pos-caution: var(--warn);
  --pos-halo: #ffffff;
  --pos-ink: #111821;
  color: var(--ink);
  color-scheme: light;
}

* {
  box-sizing: border-box;
}

/* The page is one screen at every width, like an app: the header on
   top, the listening bar (while it shows) at the bottom, and the current
   tab between them. The page itself never scrolls; only a pane whose
   content can outgrow the window does, inside itself (the fleet, the
   detail), so the tally never scrolls away and nothing hides under the
   bar. */
html {
  height: 100%;
  overflow: hidden;
}

body {
  display: flex;
  flex-direction: column;
  height: 100%;
  height: 100dvh;
  margin: 0;
  overflow: hidden;
  background: var(--ground);
  color: var(--ink);
  font: 13px/1.35 var(--sans);
  font-variant-numeric: tabular-nums;
}

body > * {
  flex: none;
}

body > main {
  flex: 1 1 auto;
  min-height: 0;
}

/* Each pane holds what it positions, as the visually hidden texts, so
   nothing positioned inside a scrolled pane reaches past the window. */
.console,
.fleet,
.detail,
.listening,
.tab-empty {
  position: relative;
}

h1,
h2,
h3,
h4,
p,
dl,
dd,
ul {
  margin: 0;
}

.mono,
.num,
pre {
  font-family: var(--mono);
}

.num {
  text-align: right;
}

/* Values that keep their place (src/view/fixed.rs): a figure's box is as wide
   as its field's widest value (its min-width, set from src/words/format.rs),
   its text right-aligned in it; a state word's box is as wide as the
   widest word it can say, every other word laid over it unseen. */
.fig {
  display: inline-block;
  font-family: var(--mono);
  text-align: right;
  white-space: nowrap;
}

/* An empty figure (a live node's age, a count not yet heard) keeps a
   line, so its baseline and its neighbours' stay where a value puts
   them. */
.fig:empty::before {
  content: "\200b";
}

.words {
  display: inline-grid;
  justify-items: center;
}

.words > * {
  grid-area: 1 / 1;
}

.words > .ghost {
  visibility: hidden;
}

/* Kept in the layout, unseen, so its showing moves nothing. */
.unseen {
  visibility: hidden;
}

.quiet,
.absent {
  color: var(--muted);
}

.hidden {
  display: none;
}

.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

:focus-visible {
  outline: 2px solid var(--select);
  outline-offset: -2px;
}

button {
  font: inherit;
  color: var(--ink);
  background: var(--raised);
  border: 1px solid var(--rule);
  border-radius: 3px;
  padding: 2px 10px;
  cursor: pointer;
}

button:hover {
  border-color: var(--muted);
}

/* Tones: text in the tone; an abnormal state's pill adds a faint wash. */

.tone-good {
  --tone: var(--good);
}

.tone-warn {
  --tone: var(--warn);
}

.tone-bad {
  --tone: var(--bad);
}

.tone-info {
  --tone: var(--info);
}

.tone-idle {
  --tone: var(--idle);
}

.pill,
.tag {
  display: inline-block;
  padding: 0 5px;
  border: 1px solid color-mix(in srgb, var(--tone) 55%, transparent);
  border-radius: 3px;
  /* A faint wash, so the 12 px text keeps 4.5:1 on every background. */
  background: color-mix(in srgb, var(--tone) 10%, transparent);
  color: var(--tone);
  font-size: 12px;
  line-height: 18px;
  text-align: center;
  white-space: nowrap;
}

/* A pill hugs its own word, at the start of a slot as wide as its
   widest word set as a pill (src/view/fixed.rs), so every state's word starts
   at the same place and what follows the slot never moves. */
.pill-slot {
  justify-items: start;
}

/* A word's unseen pill has no tone, which would void the border its
   width counts. */
.pill-slot > .ghost {
  border: 1px solid transparent;
}

.tag {
  margin-right: 3px;
}

button.words {
  display: inline-grid;
}

/* Every state is a pill, and only an abnormal one (warn, bad) is
   coloured: a normal state (live, lock, connected, GM, unkn) has a
   neutral outline and no wash. Green boxes on 128 rows would compete
   with the one stalled node's (ISA-101: colour draws the eye to
   abnormal situations only). A field the node has not sent is a dash
   with no outline: it is no state. */
.pill.tone-good,
.pill.tone-info,
.pill.tone-idle {
  border-color: color-mix(in srgb, var(--muted) 45%, transparent);
  background: none;
}

.pill.tone-absent {
  border-color: transparent;
  background: none;
}

.pill.tone-absent {
  color: var(--muted);
}

.pill.tone-good {
  color: var(--ink);
}

/* The version mismatch banner. */

/* Over the page's top, not before it: the page has stopped, and its
   showing moves nothing nor pushes the page past the window. */
.banner {
  position: fixed;
  inset: 0 0 auto;
  z-index: 10;
  display: flex;
  gap: 16px;
  align-items: center;
  padding: 10px 16px;
  background: color-mix(in srgb, var(--bad) 22%, var(--ground));
  border-bottom: 1px solid var(--bad);
  font-size: 14px;
}

.banner p {
  flex: 1;
}

/* The header. */

.masthead {
  display: grid;
  grid-template-columns: auto auto 1fr;
  grid-template-areas:
    "brand link ingest"
    "tally tally tally"
    "tabs tabs tabs";
  gap: 8px 28px;
  align-items: center;
  padding: 10px 16px 0;
  background: var(--panel);
  border-bottom: 1px solid var(--rule);
}

.brand {
  grid-area: brand;
  display: flex;
  gap: 10px;
  align-items: baseline;
}

.brand h1 {
  font-size: 17px;
  font-weight: 600;
  letter-spacing: 0.01em;
}

/* Room for a release and its commit, so the hello's version replacing
   "version unknown" moves nothing. */
.hub-version {
  display: inline-block;
  min-width: 18ch;
  color: var(--muted);
  font-family: var(--mono);
  font-size: 12px;
}

.link {
  grid-area: link;
  display: flex;
  gap: 8px;
  align-items: center;
}



/* The ingest totals and the daemon's health, each in its reserved
   width, as two rows of four that line up, rather than a row that wraps
   one figure onto a line of its own. */
.ingest {
  grid-area: ingest;
  display: grid;
  grid-template-columns: repeat(4, auto);
  gap: 4px 24px;
  justify-content: end;
}

.ingest > div {
  display: flex;
  gap: 8px;
  align-items: baseline;
}

.ingest dt {
  color: var(--muted);
}

.ingest dd {
  font-family: var(--mono);
}

/* A figure that opens its breakdown: plain text, underlined dotted. */
.figure-button {
  padding: 0;
  border: 0;
  background: none;
  color: inherit;
  text-decoration: underline dotted;
  text-underline-offset: 3px;
}

/* The breakdown, in the top layer under the header: opening it moves
   nothing. */
/* As wide at every count, so a count's growing moves nothing. */
.breakdown {
  inset: auto 16px auto auto;
  top: 48px;
  width: min(420px, calc(100vw - 32px));
  margin: 0;
  padding: 8px 12px;
  background: var(--raised);
  border: 1px solid var(--rule);
  border-radius: 3px;
  color: var(--ink);
  box-shadow: 0 6px 18px rgb(0 0 0 / 35%);
  text-align: left;
}

.breakdown h2 {
  margin-bottom: 4px;
  color: var(--muted);
  font-size: 12px;
  font-weight: 500;
}

.breakdown ul {
  margin: 0;
  padding: 0;
  list-style: none;
  font-family: var(--mono);
}

.ingest dd.tone-warn,
.ingest dd.tone-bad {
  color: var(--tone);
}

/* Why a figure is abnormal, in a box of its own beside it. */
.fig-note {
  display: inline-block;
  margin-left: 6px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  vertical-align: bottom;
}

/* The tally: the one loud element. A strip as wide as the page whose
   segments grow with each liveness count, so a lost node shows as a red
   gap from across the room. */

.tally {
  grid-area: tally;
  display: grid;
  gap: 6px;
  margin: 0 -16px;
}

.counts {
  display: flex;
  flex-wrap: wrap;
  gap: 4px 18px;
  align-items: baseline;
  padding: 0 16px;
  font-size: 12px;
}

/* While the connection is down: how old the data on screen is, and the
   last error, in the tally's line, so the header does not reflow. */
.stale-note {
  display: flex;
  gap: 12px;
  min-width: 0;
  color: var(--warn);
}

.link-error {
  display: inline-block;
  overflow: hidden;
  color: var(--muted);
  text-overflow: ellipsis;
  white-space: nowrap;
}

.count {
  color: var(--tone, var(--muted));
}

.count.zero {
  color: var(--muted);
}

.count .num {
  font-size: 15px;
  font-weight: 600;
}

.count.total {
  margin-left: auto;
}

.bar {
  order: 2;
  display: flex;
  height: 6px;
  background: var(--rule);
}

.segment {
  flex: 0 1 0;
  background: var(--tone);
}

.segment + .segment {
  border-left: 1px solid var(--panel);
}

/* The tab bar: under the tally, its selected tab joined to the page. */

.tabs {
  grid-area: tabs;
  display: flex;
  gap: 2px;
  margin-bottom: -1px;
}

.tab {
  padding: 7px 16px 6px;
  color: var(--muted);
  text-decoration: none;
  border: 1px solid transparent;
  border-bottom: none;
  border-radius: 4px 4px 0 0;
}

.tab:hover {
  color: var(--ink);
}

.tab.selected {
  color: var(--ink);
  background: var(--ground);
  border-color: var(--rule);
  box-shadow: inset 0 2px 0 var(--select);
}

/* The replay's strip over the header, on every tab of /replay/
   (src/view/replay.rs): amber, with the header tinted to match, so no one
   reads a replay as the live fleet. Each field keeps its width, so the
   lines it wraps to follow the window's width alone. */

.replay-strip {
  display: flex;
  flex-wrap: wrap;
  gap: 6px 18px;
  align-items: center;
  padding: 8px 16px;
  background: color-mix(in srgb, var(--warn) 24%, var(--ground));
  border-bottom: 2px solid var(--warn);
}

.replay-mark {
  padding: 2px 10px;
  border-radius: 3px;
  background: var(--warn);
  color: var(--ground);
  font-weight: 700;
  letter-spacing: 0.14em;
}

.replay-field,
.replay-losses,
.replay-actions {
  display: inline-flex;
  gap: 6px;
  align-items: baseline;
  white-space: nowrap;
}

.replay-losses {
  gap: 18px;
}

.replay-label {
  color: var(--muted);
}

.replay-losses.tone-bad .fig {
  color: var(--bad);
}

.replay-actions {
  gap: 14px;
  margin-left: auto;
}

/* The clock's controls: Pause or Play in one slot, Restart, and the seek
   bar, which takes the room left on its line. */
.replay-clock {
  display: inline-flex;
  gap: 8px;
  align-items: center;
  flex: 1 1 240px;
  min-width: 0;
}

.replay-seek {
  flex: 1 1 120px;
  min-width: 80px;
  margin: 0;
  accent-color: var(--warn);
}

.replay-incomplete {
  flex-basis: 100%;
  margin: 0;
  color: var(--bad);
}

.replay-live {
  color: var(--ink);
}

.masthead.replay {
  background: color-mix(in srgb, var(--warn) 9%, var(--panel));
}

/* A phone: three lines, REPLAY with its state, why it is incomplete and
   Stop, then the name and the position, then the losses and the way back,
   in a smaller face. */
@media (max-width: 720px) {
  .replay-strip {
    gap: 4px 10px;
    padding: 4px 12px;
    font-size: 11px;
  }

  .replay-place {
    display: none;
  }

  .replay-actions {
    display: contents;
  }

  .replay-stop {
    order: 1;
  }

  .replay-strip > .replay-field {
    order: 2;
  }

  .replay-losses {
    order: 3;
  }

  .replay-clock {
    order: 3;
    flex-basis: 100%;
  }

  .replay-live {
    order: 4;
  }

  /* Why the replay holds less, on the first line between the state and
     Stop, cut there: its label reads it whole. */
  .replay-incomplete {
    flex: 1 1 0;
    min-width: 0;
  }
}

/* A recording's name: as wide as the longest the daemon gives. */
.name-box {
  display: inline-block;
  overflow: hidden;
  font-family: var(--mono);
  text-overflow: ellipsis;
  white-space: nowrap;
  vertical-align: bottom;
}

/* One line that keeps its room when empty; a longer text is cut, and a
   keyboard reads it whole from its label. */
.held-line {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.held-line:empty::before {
  content: "\200b";
}

/* The Recording tab (src/view/recording.rs): the recorder, then the
   recordings, the page scrolling on its own. */

main.recording {
  position: relative;
  display: grid;
  gap: 20px;
  align-content: start;
  padding: 16px;
  overflow-y: auto;
  scrollbar-gutter: stable;
}

.recorder,
.recordings {
  display: grid;
  gap: 8px;
  align-content: start;
}

.recorder h2,
.recordings h2 {
  font-size: 15px;
  font-weight: 600;
}

.recorder-head {
  display: flex;
  flex-wrap: wrap;
  gap: 8px 16px;
  align-items: center;
}

.recorder-figures {
  display: flex;
  flex-wrap: wrap;
  gap: 6px 28px;
}

.recorder-figures > div {
  display: grid;
  gap: 2px;
}

.recorder-figures dt {
  color: var(--muted);
  font-size: 12px;
}

.recorder-figures dd {
  font-family: var(--mono);
}

.recorder-figures .tone-bad dd,
.recorder-failure {
  color: var(--bad);
}

.recorder-note,
.recorder-room,
.recordings-note {
  color: var(--muted);
}

.recorder-space.tone-bad .recorder-room {
  color: var(--bad);
}

.recording-list {
  display: grid;
  padding: 0;
  list-style: none;
}

.recording-line {
  display: flex;
  flex-wrap: wrap;
  gap: 4px 16px;
  align-items: center;
  padding: 4px 0;
  border-bottom: 1px solid var(--rule);
}

.recording-head {
  color: var(--muted);
}

.recording-line .cell {
  flex: none;
  overflow: hidden;
  font-family: var(--mono);
  text-overflow: ellipsis;
  white-space: nowrap;
}

.recording-line .tone-bad,
.recording-line .tone-warn,
.recording-line .tone-good {
  color: var(--tone);
}

/* An empty tab: Map, until it is built. */

.tab-empty {
  overflow-y: auto;
  scrollbar-gutter: stable;
}

/* The console: the table and the detail side by side, each scrolling on
   its own, so the table's column headers stay at its top. */

.console {
  display: grid;
  /* The detail at least 360 px, so a count's exact u64 fits beside its
     label past the pane's scrollbar gutter. */
  grid-template-columns: minmax(0, 1fr) minmax(360px, 25%);
  grid-template-rows: minmax(0, 1fr);
}

/* The connection is down: everything that shows the fleet turns grey, so
   no colour on it reads as current. */
.console.stale .fleet,
.console.stale .detail,
.masthead.stale .ingest,
.masthead.stale .tally .bar,
.masthead.stale .tally .count {
  opacity: 0.6;
  filter: grayscale(1);
}

/* The fleet's pane is a container (src/view/pane.rs): narrower than the
   table, it shows the cards. Its font is the rows' monospace, which the
   container query's ch reads; what it holds sets the page's sans. Its
   scrollbar's gutter stays, so the table and the cards, of other
   heights, give it one width. */
.fleet {
  overflow: auto;
  /* A row reached with the keyboard scrolls clear of the sticky header. */
  scroll-padding-top: 48px;
  container: fleet / inline-size;
  scrollbar-gutter: stable;
  font-family: var(--mono);
}

.fleet > * {
  font-family: var(--sans);
}

/* Widened by the container query, unseen. */
.fleet-probe {
  position: absolute;
  top: 0;
  left: 0;
  width: 0;
  height: 0;
  visibility: hidden;
}

/* The table's rows are grids on one set of column tracks, set from
   src/view/table.rs (--fleet-columns), so no cell's content sizes a column: a
   changed cell lays out its own row only, and a row out of sight is
   skipped (content-visibility). The tracks are in the monospace font's
   ch, the rows' own font; the cells keep the page's. The header row
   group sticks, as one, to the pane's top. */
.fleet table,
.fleet thead,
.fleet tbody {
  display: block;
}

.fleet table {
  min-width: min-content;
}

.fleet thead {
  position: sticky;
  top: 0;
  z-index: 2;
  background: var(--ground);
}

.fleet tr {
  display: grid;
  grid-template-columns: var(--fleet-columns);
  font-family: var(--mono);
}

/* A row out of sight keeps a row's height: its two lines, its padding
   and its rule (2 px under the attention group), so its being laid out
   again moves nothing below it. */
.fleet tr.node {
  content-visibility: auto;
  contain-intrinsic-size: auto calc(2 * 1.35em + 11px);
}

.fleet tbody.attention tr.node:last-child {
  contain-intrinsic-size: auto calc(2 * 1.35em + 12px);
}

.fleet tr > * {
  min-width: 0;
  font-family: var(--sans);
}

.fleet tr > .num {
  font-family: var(--mono);
}

.fleet th {
  align-self: end;
  padding: 6px 6px;
  background: var(--ground);
  border-bottom: 1px solid var(--rule);
  color: var(--muted);
  font-weight: 500;
  font-size: 12px;
  text-align: left;
  white-space: nowrap;
}

.fleet th.num {
  font-family: var(--sans);
  text-align: right;
}

.fleet td {
  padding: 5px 6px;
  border-bottom: 1px solid color-mix(in srgb, var(--rule) 60%, transparent);
  white-space: nowrap;
}

.node {
  cursor: pointer;
}

/* The nodes needing attention come first; a rule under the last of them
   ends the group. */
.fleet tbody.attention tr:last-child td {
  border-bottom: 2px solid var(--rule);
}

.node:hover {
  background: color-mix(in srgb, var(--raised) 60%, transparent);
}

.node.selected {
  background: var(--raised);
}

/* The Node column is opaque, in the row's own background, and carries
   the selected row's mark; it stays put should the table scroll
   sideways in a font wider than the one its tracks were measured in. */
.fleet th:first-child,
.fleet td.address {
  position: sticky;
  left: 0;
  box-shadow: inset -1px 0 0 color-mix(in srgb, var(--rule) 60%, transparent);
}

.fleet th:first-child {
  z-index: 3;
  align-self: stretch;
  display: flex;
  align-items: flex-end;
}

.fleet td.address {
  z-index: 1;
  background: var(--ground);
}

.node:hover > td.address {
  background: color-mix(in srgb, var(--raised) 60%, var(--ground));
}

.node.selected > td.address {
  background: var(--raised);
  box-shadow:
    inset 3px 0 0 var(--select),
    inset -1px 0 0 color-mix(in srgb, var(--rule) 60%, transparent);
}

/* The play control beside the address, both counted in the cell's width,
   so the next cell's pill never overlaps the address. */
.node-id {
  display: flex;
  gap: 6px;
  align-items: flex-start;
}

/* The name and the identity on a line each, every line kept whatever it
   holds (an absent identity keeps its line), each cut to an ellipsis
   where it outgrows the column; the row's label has both whole. */
.address .mono {
  display: block;
  font-size: 13px;
}

.node-id > div {
  flex: 1 1 auto;
  min-width: 0;
}

.address .mono,
.address .identity {
  display: block;
  height: calc(13px * 1.35);
  overflow: hidden;
  line-height: calc(13px * 1.35);
  text-overflow: ellipsis;
  white-space: nowrap;
}

.identity {
  color: var(--muted);
  font-family: var(--mono);
  font-size: 11px;
}

/* A lost node: its timing, status, faults and records are the last ones
   heard, so they turn grey; only its liveness keeps its colour. Grey by
   the muted and idle tokens, each 4.5:1 or more on the page's grounds,
   rather than faded, which took the faults' tags below 3.1:1. */
.node.held td:not(.address):nth-child(n + 3),
.card.held .card-facts,
.detail-body.held {
  --tone: var(--idle);
  color: var(--muted);
}

:is(.node.held td:not(.address):nth-child(n + 3), .card.held .card-facts, .detail-body.held)
  :is(.pill, .tag, .nonzero, .slot.present, .flag.on, .flag.on .mark, .pill.tone-good) {
  --tone: var(--idle);
  color: var(--muted);
}

/* No wash under a held pill: on the selected row's ground it took the
   muted text to 4.1:1. */
:is(.node.held td:not(.address):nth-child(n + 3), .card.held .card-facts, .detail-body.held)
  :is(.pill, .tag) {
  background: none;
}

/* Last in the detail, stuck to its pane's foot, so it covers nothing it
   would otherwise push down. */
.held-note {
  position: sticky;
  bottom: 0;
  z-index: 1;
  padding: 8px 14px;
  border-top: 1px solid var(--bad);
  background: color-mix(in srgb, var(--bad) 14%, var(--panel));
  color: var(--ink);
}

/* A lock state and its offset in one cell: the pill, then the offset
   right-aligned in its figure's width so the column's numbers line up.
   In the table the pill sits over the offset, on the row's two lines. */
.lock-offset {
  margin-left: 6px;
  font-size: 12px;
}

.fleet td.lock {
  display: grid;
  grid-template-rows: repeat(2, 1.35em);
  align-items: center;
  justify-items: start;
}

.fleet td.lock .lock-offset {
  margin-left: 0;
}

/* The table's pills and tags are 17 px tall, so a pill and a figure, or
   two lines of tags, fill a row's two lines. */
.fleet tr .pill {
  line-height: 15px;
}

/* The board's figures: the temperature over the pack voltage, the
   alignment beside the temperature; its header names each where it
   sits. The tracks are each figure's width in the rows' font
   (src/view/table.rs). */
.fleet td.board,
.board-grid {
  display: grid;
  column-gap: 8px;
  justify-content: start;
  font-family: var(--mono);
  font-size: 13px;
}

.board-grid > span {
  font-family: var(--sans);
  font-size: 12px;
}

/* Two lines of record slots: records 1 to 5 over 6 to 10. The fifth
   column holds record 10's two digits whether it is present or not, so a
   record arriving moves nothing. */
.fleet td.records {
  display: grid;
  grid-template-columns: repeat(5, max-content);
  align-content: start;
}

.fleet td.records .slot:nth-child(5n) {
  width: 2ch;
}

/* How long a node has been silent: its box is there while it is live, so
   the column keeps its width when the node falls silent. */
.age {
  margin-left: 6px;
  color: var(--muted);
  font-size: 12px;
}

/* Its tracks are each counter's width (src/view/table.rs). */
.stream-grid {
  display: grid;
  gap: 0 8px;
  font-family: var(--mono);
}

/* Every cell of a row two lines tall, whatever it holds: two streams'
   lines even when the node has fewer, the fault tags in their box, a
   name and its identity. No cell's content changes the row's height, so
   nothing moves the rows below. The height is the content's alone, so
   the cell's padding and its border (1 px, or 2 px under the attention
   group) come on top of it. */
.fleet tr.node > td {
  box-sizing: content-box;
  height: calc(2 * 1.35em);
  overflow: hidden;
}

/* The header's tracks in the rows' font, as the cells' are; its names in
   the header's. */
.streams-head .stream-grid {
  font-size: 13px;
}

.streams-head .stream-grid > * {
  grid-row: 2;
  font-family: var(--sans);
  font-size: 12px;
}

.streams-head .group {
  grid-row: 1;
  grid-column: 1 / -1;
  border-bottom: 1px solid var(--rule);
  text-align: center;
}

.port {
  color: var(--muted);
}

.nonzero {
  color: var(--warn);
}

/* The fault tags in a box two lines tall (src/view/table.rs): as many as
   fit, then +N. Tags a little shorter than elsewhere, so two lines of
   them are the two text lines of a row. */
.fault-box {
  /* The page measures the tags in this font (src/view/tag_fit.rs), wherever
     the box sits (a detail value is monospace). */
  font-family: var(--sans);
  display: flex;
  flex-wrap: wrap;
  align-content: flex-start;
  gap: 1px 3px;
  height: calc(2 * 1.35em);
  overflow: hidden;
  white-space: nowrap;
}

.fault-box .tag {
  margin-right: 0;
  line-height: 15px;
}

/* "none" and the dash sit on a tag's line. */
.fault-box > :not(.tag) {
  border: 1px solid transparent;
  line-height: 15px;
}



.slot {
  display: inline-block;
  width: 1.7ch;
  margin-right: 1px;
  color: color-mix(in srgb, var(--muted) 75%, transparent);
  font-family: var(--mono);
  text-align: center;
}

/* A present record is normal: its number in plain text, beside the
   absent ones' dots. */
.slot.present {
  color: var(--ink);
}

.empty {
  padding: 24px 16px;
  color: var(--muted);
}

/* The fleet as cards, on a phone (src/view/cards.rs): the node and its
   liveness on top, then faults, pack, PTP, audio and packets on a line
   each, every line one line tall whatever it says. */

.cards {
  margin: 0;
  padding: 0;
  list-style: none;
}

/* The rule's second pixel is drawn inside the card, so a card joining or
   leaving the group keeps its height and nothing below it moves. */
.cards.attention li:last-child {
  border-bottom-color: var(--rule);
  box-shadow: inset 0 -1px 0 var(--rule);
}

.cards.attention li.selected:last-child {
  box-shadow:
    inset 3px 0 0 var(--select),
    inset 0 -1px 0 var(--rule);
}

/* A card out of sight is not laid out. */
.card {
  content-visibility: auto;
  /* A card out of sight keeps a card's content height, so its being
     laid out again moves nothing below it: the name's and the
     identity's lines, then five facts' lines (the faults take two, PTP
     and audio a 20 px pill each), and the gaps between them. The
     browser lays each line out at a whole 1/64 px, rounded down, so the
     lines are summed as laid out, not as 1.35 times the font. */
  contain-intrinsic-size: auto
    calc(5 * round(down, 13px * 1.35, 1px / 64) + round(down, 11px * 1.35, 1px / 64) + 58px);
  display: grid;
  /* One column no wider than the card, so a long name is cut with its
     ellipsis rather than widening the card past the pane. */
  grid-template-columns: minmax(0, 1fr);
  gap: 6px;
  padding: 8px 12px 10px;
  border-bottom: 1px solid color-mix(in srgb, var(--rule) 60%, transparent);
}

.card.selected {
  box-shadow: inset 3px 0 0 var(--select);
}

.card-head {
  display: flex;
  gap: 8px;
  align-items: flex-start;
  justify-content: space-between;
}

/* The name cut with an ellipsis rather than pushing the liveness down,
   and the identity's line there before record 1 brings it. */
.card-head .node-id {
  flex: 1 1 auto;
  min-width: 0;
}

.card-head .node-id .mono,
.card-head .identity {
  display: block;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.card-head .identity {
  min-height: calc(11px * 1.35);
}

.card-liveness {
  flex: none;
  white-space: nowrap;
}

.card-facts {
  display: flex;
  flex-wrap: wrap;
  gap: 3px 16px;
}

/* Each fact aligned to its line's top, not to a baseline that moves with
   what the value holds (tags, a dash, "no live stream"). */
.card-facts > div {
  display: flex;
  flex: 1 1 140px;
  gap: 8px;
  align-items: flex-start;
  min-width: 0;
}

.card-facts > div.wide {
  flex-basis: 100%;
}

.card-facts > div > dt {
  flex: none;
  width: 4.5em;
  color: var(--muted);
}

.card-facts > div > dd {
  display: flex;
  flex-wrap: nowrap;
  overflow: hidden;
  white-space: nowrap;
  gap: 0 8px;
  align-items: baseline;
  min-width: 0;
}

.card-facts .lock-offset {
  margin-left: 0;
}



/* The detail panel. */

.detail {
  overflow-y: auto;
  scrollbar-gutter: stable;
  background: var(--panel);
  border-left: 1px solid var(--rule);
}

/* The node's name and controls stay at the pane's top as it scrolls. */
.detail-head {
  position: sticky;
  top: 0;
  z-index: 2;
  background: var(--panel);
  display: flex;
  flex-wrap: wrap;
  gap: 8px 10px;
  align-items: center;
  padding: 10px 14px;
  border-bottom: 1px solid var(--rule);
}

/* As wide as the longest address, so the pill after it keeps its place. */
.detail-head h2 {
  min-width: 15ch;
  font-size: 16px;
  font-weight: 600;
}

.detail-head button {
  white-space: nowrap;
}

/* The controls on a line of their own, at its end: a long name and its
   pill keep the first line, so the head is two lines at every name. */
.detail-actions {
  display: flex;
  flex-basis: 100%;
  gap: 8px;
  justify-content: flex-end;
}

.detail-head .listen.live {
  border-color: var(--good);
  color: var(--good);
}

/* Plain HTTP cannot listen: the way to the hub's HTTPS address. */
.detail-head .listen-https {
  align-self: center;
  color: var(--info);
  font-size: 12px;
  white-space: nowrap;
}

/* The Now block (src/node/vitals.rs): fixed lines in the figures' font, each
   a word and its figures in a box as wide as their widest value, so a changing
   value moves nothing; a trend's text sits in a box of its samples. Then the
   problem slots (src/node/faults.rs), each a line whatever it holds. */
.now {
  padding: 8px 14px;
  border-bottom: 1px solid var(--rule);
}

.now h3 {
  margin-bottom: 4px;
  font-size: 13px;
  font-weight: 600;
}

.vitals {
  display: grid;
  margin-bottom: 6px;
  font-family: var(--mono);
  font-size: 12px;
}

.vital {
  display: grid;
  grid-template-columns: 8ch minmax(0, 1fr) auto;
  gap: 1ch;
  white-space: nowrap;
}

.vital-label {
  color: var(--muted);
}

.vital-word,
.vital-value,
.vital-limit {
  color: var(--tone);
}

.vital-word.tone-good,
.vital-value.tone-good,
.vital-limit.tone-good {
  color: var(--ink);
}

.vital-limit.tone-good {
  color: var(--muted);
}

.vital-trend {
  display: flex;
  justify-content: space-between;
  gap: 1ch;
  height: 1lh;
  white-space: nowrap;
}

.spark {
  overflow: hidden;
  color: var(--muted);
}

.spark:empty::before {
  content: "\200b";
}

.problems {
  display: grid;
  padding: 0;
  list-style: none;
  font-size: 12px;
}

.problem {
  display: grid;
  grid-template-columns: 16ch minmax(0, 1fr);
  gap: 1ch;
  height: 1lh;
  white-space: nowrap;
}

.problem-source {
  color: var(--bad);
}

.problem-source.quiet {
  color: var(--muted);
}

.problem-reason {
  overflow: hidden;
  color: var(--ink);
  text-overflow: ellipsis;
}

.problems-more {
  height: 1lh;
  color: var(--muted);
  font-size: 12px;
}

.problems-more:empty::before,
.problem-source:empty::before,
.problem-reason:empty::before {
  content: "\200b";
}

/* The Recent block (src/node/episodes.rs): the flap line in a box that holds
   its longest text, three lanes whose inline image is their value, and fixed
   rows of an episode, blank past the history's end. */
.recent {
  padding: 8px 14px;
  border-bottom: 1px solid var(--rule);
  font-size: 12px;
}

.recent h3 {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  margin-bottom: 4px;
  font-size: 13px;
  font-weight: 600;
}

.recent-window {
  color: var(--muted);
  font-size: 12px;
  font-weight: 400;
}

.flap-line {
  height: calc(2 * 1lh);
  color: var(--muted);
  overflow-wrap: anywhere;
}

.flap-line.flapping {
  color: var(--warn);
  font-weight: 600;
}

.lane-row {
  display: grid;
  grid-template-columns: 8ch minmax(0, 1fr);
  gap: 1ch;
  align-items: center;
  height: 1lh;
  color: var(--muted);
  font-family: var(--mono);
}

.lane {
  display: block;
  height: 10px;
  background-color: color-mix(in srgb, var(--muted) 18%, transparent);
}

.episodes {
  display: grid;
  gap: 2px;
  margin-top: 6px;
  padding: 0;
  list-style: none;
}

.episode {
  display: grid;
}

.episode > span {
  overflow: hidden;
  height: 1lh;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.episode > span:empty::before {
  content: "\200b";
}

.episode-when {
  color: var(--muted);
}

/* A closable section (src/view/detail/sections.rs): its title and one line
   that says whether to open it. The line's text changes in its own box at
   the line's end; opening moves what is below only by the operator's act. */
.sections-bar {
  display: flex;
  justify-content: flex-end;
  padding: 6px 14px;
  border-bottom: 1px solid var(--rule);
}

.section {
  border-bottom: 1px solid var(--rule);
}

.section > summary {
  display: flex;
  gap: 6px;
  align-items: baseline;
  padding: 6px 14px;
  cursor: pointer;
  list-style: none;
}

.section > summary::-webkit-details-marker {
  display: none;
}

.section-mark {
  width: 1ch;
  color: var(--muted);
  font-family: var(--mono);
}

.section h3 {
  font-size: 13px;
  font-weight: 600;
  white-space: nowrap;
}

.section-summary {
  margin-left: auto;
  color: var(--tone);
  font-size: 12px;
  white-space: nowrap;
}

.section-summary.tone-idle {
  color: var(--muted);
}

.section-body {
  padding: 4px 14px 10px;
}

.section-body > h4 {
  margin: 10px 0 4px;
  font-size: 13px;
  font-weight: 500;
}

.ended-toggle {
  margin: 6px 0 2px;
  color: var(--muted);
  font-size: 12px;
}

/* Each value on one line, at the line's end, in the width its field reserves
   (its min-width, from src/view/detail/fields.rs). One column at every width,
   so every value of a section ends at the same place. */
.fields {
  display: grid;
  gap: 4px;
}

/* The detail's fault tags under their label, the line aligned to its top
   rather than to a baseline that moves with what the box holds. */
.fields > div.fault-row {
  grid-template-columns: 1fr;
  gap: 0;
  align-items: start;
}

.fields > div {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 8px;
  align-items: baseline;
}

.fields dt {
  color: var(--muted);
  white-space: nowrap;
}

.fields dd,
.counters dd {
  overflow: hidden;
  font-family: var(--mono);
  text-align: right;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* An absent value (src/view/detail/fields.rs) does not weigh as a reading does. */
.fields dd.absent {
  color: var(--muted);
}

/* Only an abnormal value takes its tone. */
.fields dd.tone-warn,
.fields dd.tone-bad {
  color: var(--tone);
}

/* What a row says beside its value, a limit or a hint: its own box, so the
   value's right edge holds. */
.fields .beside {
  display: inline-block;
  margin-left: 1ch;
  color: var(--muted);
}

.fields .beside.tone-warn,
.fields .beside.tone-bad {
  color: var(--tone);
}

/* An empty value keeps its line, as an empty figure does. */
.fields dd:empty::before {
  content: "\200b";
}

/* A decoder's text that can outgrow a line (src/view/detail/fields.rs,
   Field::lines): whole, under its label, in a box the line's width and as
   tall as its longest text takes at the narrowest panel's, so its changing
   moves nothing. */
.fields > div.lines {
  grid-template-columns: 1fr;
  gap: 0;
  align-items: start;
}

.fields > div.lines dd {
  overflow-wrap: anywhere;
  text-align: left;
  white-space: pre-line;
}

.flags {
  display: flex;
  flex-wrap: wrap;
  gap: 4px 12px;
  margin-top: 8px;
  padding: 0;
  list-style: none;
  color: var(--muted);
}

.flags-head {
  margin-top: 8px;
  color: var(--muted);
  font-size: 12px;
}

.flags-head + .flags {
  margin-top: 2px;
}

.flags-note {
  margin-top: 2px;
  color: var(--muted);
  font-size: 12px;
}

/* A section's lists read as one. */
.fields + .fields {
  margin-top: 4px;
}

.flag.on {
  color: var(--ink);
}

.flag .mark {
  margin-right: 4px;
  /* The empty box is the clear flag's one non-colour cue: 3:1 at least
     (WCAG 1.4.11). */
  color: var(--muted);
}

.flag.on .mark {
  color: var(--ink);
}

.stream,
.record {
  margin-top: 8px;
  padding-top: 8px;
  border-top: 1px dashed var(--rule);
}

.stream:first-of-type,
.record:first-of-type {
  border-top: 0;
}

.stream h4,
.record h4 {
  display: flex;
  flex-wrap: wrap;
  gap: 4px 10px;
  margin-bottom: 6px;
  font-size: 13px;
  font-weight: 500;
}

/* A section's "What these mean" (src/view/detail/sections.rs): closed, a line. */
.notes {
  margin-top: 8px;
  color: var(--muted);
  font-size: 12px;
}

.notes summary {
  cursor: pointer;
}

.notes ul {
  display: grid;
  gap: 3px;
  margin: 4px 0 0;
  padding: 0;
  list-style: none;
}

/* A record's one fixed line (src/view/detail.rs, record): its number, name,
   size and age; its contents open under it. */
.record summary {
  display: flex;
  gap: 6px;
  align-items: baseline;
  cursor: pointer;
  list-style: none;
}

.record summary::-webkit-details-marker {
  display: none;
}

.record summary h4 {
  display: block;
  margin-bottom: 0;
  white-space: nowrap;
}

.record-age {
  margin-left: auto;
  font-size: 12px;
  white-space: nowrap;
}

.record-version {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  margin: 4px 0;
  font-size: 12px;
}

.record-caption {
  margin: 8px 0 2px;
  color: var(--muted);
  font-size: 12px;
}

.module-health.tone-warn,
.module-health.tone-bad {
  color: var(--tone);
}


.counters {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(165px, 1fr));
  gap: 2px 16px;
}

/* The value in the rest of the line, right-aligned: its box stays put as
   the number changes. */
.counters > div {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 8px;
}

.counters > div.wide {
  grid-column: 1 / -1;
}

.stream-ssrc {
  flex-basis: 100%;
}

.gaps-more summary,
.position-why {
  margin-top: 6px;
  color: var(--muted);
  font-size: 12px;
}

.gaps-more summary {
  cursor: pointer;
}

/* Two lines at the narrowest pane, its longest text, so a first position moves
   nothing. */
.position-why {
  height: calc(2 * 1lh);
}

.position-why:empty::before {
  content: "\200b";
}

/* A slot past the gaps held keeps its line. */
.gaps.recent td:empty::before {
  content: "\200b";
}

.counters dt {
  color: var(--muted);
}

.gaps {
  width: 100%;
  margin-top: 6px;
  border-collapse: collapse;
  font-size: 12px;
}

.gaps caption {
  color: var(--muted);
  text-align: left;
}

.gaps th {
  color: var(--muted);
  font-weight: 500;
}

.gaps th,
.gaps td {
  padding: 1px 6px;
}

/* Columns as wide as their widest values (src/view/detail.rs), set in the
   figures' font, so no value changes them; each gap's frame on a line of
   its own below. */
.gaps.recent {
  table-layout: fixed;
  font-family: var(--mono);
}

.gaps.recent th {
  font-family: var(--sans);
}

.gaps.recent .gap-frame td {
  padding-bottom: 3px;
  color: var(--muted);
  text-align: right;
}

.gaps.recent td {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* All sources (src/node/faults.rs): a line per source, its label, its reason
   and its pill; then where reading stands, in a box two
   lines tall that holds its longest text, and the counts, a line each.
   Nothing appears or goes, so a fault moves no line. */
.fault-lines {
  display: grid;
  gap: 2px;
  margin-bottom: 6px;
  padding: 0;
  list-style: none;
}

.fault-line {
  display: grid;
  grid-template-columns: 14ch minmax(0, 1fr) auto;
  column-gap: 6px;
  align-items: baseline;
  font-size: 12px;
}

.fault-source {
  white-space: nowrap;
}

.fault-reason {
  overflow: hidden;
  color: var(--muted);
  font-size: 11px;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* An ok line is plain text, so a faulty pill is what the eye finds; a source
   not built draws the eye no more than one that passes. */
.fault-line .pill.tone-good,
.fault-line .pill.tone-idle {
  border-color: transparent;
}

.fault-group {
  margin-top: 6px;
  color: var(--muted);
  font-size: 12px;
}

.fault-group:first-child {
  margin-top: 0;
}

.fault-note {
  color: var(--muted);
  font-size: 12px;
  white-space: nowrap;
}

.fault-note + .fields {
  margin-top: 6px;
}

.fault-note.read-note {
  height: calc(2 * 1lh);
  white-space: normal;
}

.fault-reason:empty::before,
.fault-note:empty::before,
.command-note:empty::before,
.transition-mark:empty::before,
.transition-what:empty::before,
.transition-clock:empty::before,
.transition-when > span:empty::before {
  content: "\200b";
}

/* A command's line (src/node/command.rs), in the Actions section:
   its name and its button, or Confirm and Cancel laid over it in one slot as
   wide as the wider pair, and its note under them on four reserved lines (the
   longest, a clear asked again after an unknown outcome, names the node
   and warns: four lines at the pane's least width with the longest
   names), so no step of a command moves anything. */
.command-lines {
  display: grid;
  gap: 4px;
  padding: 0;
  list-style: none;
}

.command-line {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  column-gap: 8px;
  align-items: center;
}

.command-name {
  white-space: nowrap;
}

.command-actions {
  justify-items: end;
}

.command-confirm {
  display: inline-flex;
  gap: 4px;
}

.command-note {
  grid-column: 1 / -1;
  height: calc(4 * 1lh);
  color: var(--muted);
  font-size: 12px;
  overflow-wrap: anywhere;
}

/* The journal repair's note (src/node/reset.rs): its confirmation names the
   node and states the attended conditions, asked again the longest, nine
   lines at the pane's least width with the longest names. */
.command-note.long-note {
  height: calc(9 * 1lh);
}

/* The Calibration tab (src/view/calibration.rs): the node picker, then the
   node's two procedures one above the other, each at a readable width; the
   tab scrolls inside itself, as Recording does, never the page. */
main.calibration {
  position: relative;
  display: grid;
  gap: 16px;
  align-content: start;
  padding: 16px;
  overflow-y: auto;
  scrollbar-gutter: stable;
}

.calibration-node select {
  width: 30ch;
}

.mapping-unclear {
  color: var(--warn);
  text-align: center;
}

.calibration-node,
.mapping-case {
  display: flex;
  gap: 6px;
  align-items: baseline;
  font-size: 13px;
}

.calibration-procedure {
  display: grid;
  align-content: start;
  gap: 4px;
  max-width: 640px;
}

.calibration-procedure h2 {
  font-size: 15px;
  font-weight: 600;
}

.calibration-none {
  color: var(--muted);
}

/* The detail's line per procedure (src/view/detail.rs): as a closed
   section's line, a link to the Calibration tab with the node chosen. */
.section-link {
  display: flex;
  gap: 6px;
  align-items: baseline;
  padding: 6px 14px;
  border-bottom: 1px solid var(--rule);
  color: inherit;
  text-decoration: none;
}

.section-link h3 {
  font-size: 13px;
  font-weight: 600;
  white-space: nowrap;
}

/* The magnetometer's procedure (src/view/calibration/magnetometer.rs):
   a running rotation's seconds left, large, beside the one line on how to
   turn the node, kept in the layout while none runs, the fit's fields, the
   commands in one row, and the last outcome on two reserved lines, so no
   step moves anything. */
.calibration-live {
  display: flex;
  align-items: center;
  gap: 10px;
  margin: 2px 0 6px;
}

.calibration-left {
  flex: none;
  min-width: 5ch;
  font-size: 28px;
  line-height: 1.2;
  color: var(--ink);
}

.calibration-sweep {
  height: calc(3 * 1lh);
  display: flex;
  align-items: center;
  color: var(--muted);
  font-size: 12px;
  overflow-wrap: anywhere;
}

.calibration-actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 6px;
}

.calibration-seconds input {
  width: 6ch;
  font: inherit;
  color: var(--ink);
  background: var(--raised);
  border: 1px solid var(--rule);
  border-radius: 3px;
}

.calibration-line {
  height: calc(2 * 1lh);
  margin: 4px 0 0;
  color: var(--muted);
  font-size: 12px;
  overflow-wrap: anywhere;
}

/* The Microphones section (src/view/calibration/microphones.rs): the
   enclosure's pictogram at a fixed size, its ports labelled with their
   channels, the lit one pulsing (steady under reduced motion), the one a
   tap just settled flashing green, the lit one saying "unclear, tap
   again" after an unclear tap, the enclosure's default dim and a
   port clickable for a re-tap during a session; the fields, the commands
   and the outcome line as the calibration's. */
.mapping-figure {
  display: flex;
  justify-content: center;
  margin: 2px 0 6px;
}

.mapping-pictogram {
  width: 220px;
  height: 220px;
  flex: none;
}

.mapping-pictogram .plate {
  fill: color-mix(in srgb, var(--info) 14%, transparent);
  stroke: var(--info);
  stroke-width: 2;
  stroke-linejoin: round;
}

.mapping-pictogram .notch {
  fill: var(--panel);
}

.mapping-pictogram .antenna {
  fill: none;
  stroke: var(--muted);
  stroke-width: 1.5;
}

.mapping-pictogram .port circle {
  fill: var(--raised);
  stroke: var(--rule);
  stroke-width: 1.5;
}

.mapping-pictogram .port text {
  fill: var(--ink);
  font-family: var(--mono);
  font-size: 9px;
  text-anchor: middle;
}

/* A port clicked during a session is lit for a re-tap. */
.mapping-pictogram .port.pickable {
  cursor: pointer;
}

.mapping-pictogram .port.assigned circle,
.mapping-pictogram .port.stored circle {
  stroke: var(--good);
}

.mapping-pictogram .port.default text {
  fill: var(--muted);
}

.mapping-pictogram .port.settled circle {
  fill: color-mix(in srgb, var(--good) 60%, var(--raised));
  stroke: var(--good);
  stroke-width: 2.5;
}

.mapping-pictogram .port.lit circle {
  fill: color-mix(in srgb, var(--select) 55%, var(--raised));
  stroke: var(--select);
  stroke-width: 2.5;
  animation: mapping-lit 1.2s ease-in-out infinite;
}

@keyframes mapping-lit {
  50% {
    fill: var(--raised);
  }
}

@media (prefers-reduced-motion: reduce) {
  .mapping-pictogram .port.lit circle {
    animation: none;
  }
}

/* All transitions (src/node/faults.rs): fixed entries of two lines and their
   clocks behind a disclosure, newest first, blank past the history's end, so
   a new one moves none. */
.transitions {
  display: grid;
  gap: 4px;
  padding: 0;
  list-style: none;
  font-size: 12px;
}

.transition summary {
  display: grid;
  grid-template-columns: 1ch minmax(0, 1fr);
  column-gap: 6px;
  cursor: pointer;
  list-style: none;
}

.transition summary::-webkit-details-marker {
  display: none;
}

.transition-mark {
  color: var(--muted);
  font-family: var(--mono);
}

.transition details[open] .transition-mark {
  display: inline-block;
  height: 1lh;
  transform: rotate(90deg);
}

.transition-lines,
.transition details {
  display: grid;
}

.transition-when,
.transition-what,
.transition-clock {
  white-space: nowrap;
}

.transition-when {
  display: flex;
  gap: 1ch;
}

.transition-kind {
  justify-items: start;
  color: var(--tone);
}

.transition-kind.tone-idle {
  color: var(--muted);
}

.transition-clock {
  display: block;
  padding-left: calc(1ch + 6px);
  color: var(--muted);
  font-family: var(--mono);
}

/* The CPU section (src/node/cpu.rs): fixed lines in the figures' font,
   each one text of at most 41 characters in a box that wide and a line
   high, every figure in a column of its widest text, so no value moves
   another. A missed deadline's line is bad, and says "missed". The
   disclosures move what is below them only when the operator opens one;
   the raw pages scroll in a box of their own. */
.cpu-lines {
  display: grid;
}

.cpu-line {
  width: 41ch;
  height: 1lh;
  font-family: var(--mono);
  font-size: 12px;
  white-space: pre;
}

.cpu-line.missed {
  color: var(--bad);
}

/* A row's gauge behind its label (src/node/cpu.rs, gauge): the label's 19 ch,
   filled to now, a tick at its 5 minute largest. */
.cpu-row {
  --gauge: color-mix(in srgb, var(--info) 22%, transparent);
  background-repeat: no-repeat;
  background-size: 19ch 100%;
}

.cpu-table-head,
.cpu-about {
  margin-top: 6px;
  color: var(--muted);
}

.cpu-more summary {
  margin-top: 6px;
  color: var(--muted);
  font-size: 12px;
  cursor: pointer;
}

.cpu-raw {
  height: 280px;
  max-height: none;
  margin-top: 4px;
}

/* Record 7's module list (src/view/detail.rs, module_list): two lines per
   module, each on one line, in columns as wide as the widest text each
   can hold, which its head holds unseen in the cells' weight; the table
   is no wider than its columns, so no value is cut or moves another. */
.gaps.modules {
  width: auto;
}

.gaps.modules th,
.gaps.modules td {
  text-align: left;
  white-space: nowrap;
}

/* A record's lists (src/view/detail.rs, part): each head holds every text its
   column's cells can, unseen, in the cells' weight, so no value sizes a
   column; every cell on one line, and a cell that can outgrow one whole
   on the lines it reserves below its row. */
.gaps.list th,
.gaps.list td {
  white-space: nowrap;
}

.gaps.list th .words {
  justify-items: start;
}

.gaps.list th.num .words {
  justify-items: end;
}

.gaps.list th .ghost {
  font-weight: 400;
}

.gaps.list tr.below div {
  max-width: 100%;
  overflow-wrap: anywhere;
  white-space: normal;
}

.gaps.modules th .ghost {
  font-weight: 400;
}

.gaps.modules tbody th {
  padding-top: 4px;
  font-weight: 500;
  color: var(--ink);
}

/* The module's name fills the line; its epoch, in a box as wide as any,
   keeps the line's end. */
.gaps.modules .module-head {
  display: flex;
  justify-content: space-between;
  gap: 8px;
}

.gaps.modules .module-head > :first-child {
  flex: 1;
}

.gaps.modules .epoch {
  color: var(--muted);
  font-weight: 400;
}

/* A record's flag byte: every defined flag listed, marked when set. */
.record-flags {
  margin-top: 6px;
  font-size: 12px;
}

.record-flags .caption {
  color: var(--muted);
}

.record-flags .flags {
  margin-top: 2px;
}

.undefined-bits {
  justify-items: start;
}

/* The fields the firmware does not define yet, shown raw under a
   caption of their own (src/node/records.rs, NOT_YET_DEFINED). */
.undefined {
  margin-top: 6px;
}

.undefined .caption,
.undefined-note {
  margin-bottom: 2px;
  color: var(--muted);
  font-size: 12px;
}

/* A record's raw view (src/view/detail.rs, record): its bytes and its debug
   decode in the top layer, over the page, so opening it moves nothing;
   long text scrolls inside it. */
.raw-open {
  margin-left: auto;
  padding: 0 6px;
  font-size: 12px;
}

.raw-view {
  inset: 48px 16px auto auto;
  width: min(560px, calc(100vw - 32px));
  max-height: calc(100vh - 64px);
  margin: 0;
  padding: 8px 12px;
  overflow: auto;
  background: var(--raised);
  border: 1px solid var(--rule);
  border-radius: 3px;
  color: var(--ink);
  box-shadow: 0 6px 18px rgb(0 0 0 / 35%);
}

.raw-view h2 {
  margin-bottom: 4px;
  color: var(--muted);
  font-size: 12px;
  font-weight: 500;
}

.raw-view pre {
  max-height: none;
}

pre {
  max-height: 280px;
  overflow: auto;
  padding: 8px 10px;
  background: var(--ground);
  border: 1px solid var(--rule);
  border-radius: 3px;
  font-size: 12px;
  line-height: 1.4;
  white-space: pre;
}

/* Listening: the row control, then the bar along the bottom. */

.play {
  flex: none;
  width: 22px;
  height: 18px;
  padding: 0;
  font-size: 9px;
  line-height: 16px;
  color: var(--muted);
}

.play:hover:not(:disabled),
.play.live {
  color: var(--good);
  border-color: var(--good);
}

button:disabled {
  opacity: 0.35;
  cursor: default;
}

button[aria-pressed="true"] {
  background: color-mix(in srgb, var(--select) 22%, var(--raised));
  border-color: var(--select);
}

.listening {
  z-index: 2;
  display: grid;
  /* Never wider than the page, whatever its content's widths, and never
     more than half its height: on a phone the strips scroll inside it. */
  grid-template-columns: minmax(0, 1fr);
  max-height: 50vh;
  max-height: 50dvh;
  overflow-y: auto;
  gap: 6px;
  padding: 6px 12px 8px;
  background: var(--panel);
  border-top: 1px solid var(--select);
  box-shadow: 0 -6px 18px rgb(0 0 0 / 35%);
}

.listen-head {
  display: flex;
  flex-wrap: wrap;
  gap: 6px 18px;
  align-items: center;
  white-space: nowrap;
}

.listen-node {
  display: flex;
  flex: 1 1 auto;
  flex-wrap: wrap;
  gap: 8px;
  align-items: center;
  min-width: 0;
}

/* As wide as the longest address, so the pill after it keeps its place. */
.listen-node .mono {
  display: inline-block;
  min-width: 15ch;
  font-size: 14px;
  font-weight: 600;
}

/* Why listening stopped, whole: a touch screen shows no tooltip. */
.listen-reason {
  color: var(--bad);
  white-space: normal;
  overflow-wrap: anywhere;
}

.volume {
  display: flex;
  gap: 8px;
  align-items: center;
}

.volume input {
  width: 120px;
  accent-color: var(--select);
}

.volume-db {
  font-size: 12px;
}

.segmented {
  display: flex;
}

.segmented button {
  border-radius: 0;
}

.segmented button:first-child {
  border-radius: 3px 0 0 3px;
}

.segmented button + button {
  margin-left: -1px;
  border-radius: 0 3px 3px 0;
}

.listen-stats {
  display: flex;
  flex-wrap: wrap;
  gap: 4px 16px;
}

.listen-stats div {
  display: flex;
  gap: 6px;
  align-items: baseline;
}

.listen-stats dt {
  color: var(--muted);
}

.mix-meters {
  display: grid;
  flex: 0 0 140px;
  gap: 2px;
}

/* The first column holds L, R and the scale's dB, whose 10 px text
   needs more than 2 of the column's 11 px ch. */
.mix-side {
  display: grid;
  grid-template-columns: 2.5ch 1fr;
  gap: 6px;
  align-items: center;
  font-size: 11px;
}

/* A meter: the RMS as a bar, the held peak as a tick, on a -120 to
   0 dBFS scale (METER_FLOOR_DBFS) with a mark every 30 dB
   (SCALE_MARKS_DBFS). The bar is neutral: green means healthy elsewhere
   on the page, and a level is a fact, not a state. */
.meter {
  position: relative;
  height: 8px;
  overflow: hidden;
  background:
    repeating-linear-gradient(to right, transparent 0 calc(25% - 1px), var(--rule) calc(25% - 1px) 25%),
    var(--ground);
  border: 1px solid var(--rule);
  border-radius: 2px;
}

.meter-rms {
  height: 100%;
  background: color-mix(in srgb, var(--idle) 60%, transparent);
}

/* The scale's labels under the mix meters, each centred on its mark. */
.meter-scale {
  font-size: 10px;
  line-height: 12px;
}

.meter-scale-marks {
  position: relative;
  height: 12px;
  color: var(--muted);
}

.meter-scale-marks span {
  position: absolute;
  transform: translateX(-50%);
  white-space: nowrap;
}

.meter-scale-marks span:last-child {
  transform: translateX(-100%);
}

.meter-peak {
  position: absolute;
  top: 0;
  bottom: 0;
  width: 2px;
  margin-left: -2px;
  background: var(--ink);
}

.strips {
  display: grid;
  grid-template-columns: repeat(8, minmax(0, 1fr));
  /* Rows 24 px apart, so the 18 px toggles keep WCAG 2.5.8's spacing. */
  gap: 6px 10px;
}

.strip {
  display: grid;
  grid-template-columns: 4ch minmax(24px, 1fr) auto auto auto;
  gap: 4px;
  align-items: center;
}

.strip.out .strip-label,
.strip.out .meter,
.strip.out .strip-peak {
  opacity: 0.35;
}

.strip-label {
  padding: 0 2px;
  font-size: 11px;
  line-height: 16px;
  background: transparent;
  border-color: transparent;
}

.strip-label:hover {
  border-color: var(--rule);
}

.strip-peak {
  padding: 0 2px;
  font-size: 11px;
  line-height: 16px;
  text-align: right;
  color: inherit;
  background: transparent;
  border-color: transparent;
  cursor: default;
}

.strip-peak:hover {
  border-color: transparent;
}

/* A latched clip: the strip's meter and peak in the bad tone until the
   operator clicks the peak. */
.strip.clipped .meter {
  border-color: var(--bad);
}

/* The clip's mark, in a box kept while there is none. */
.clip-mark {
  display: inline-block;
  width: 1ch;
  font-weight: 700;
  text-align: left;
}

.strip.clipped .strip-peak,
.strip.clipped .strip-peak:hover {
  color: var(--bad);
  border-color: var(--bad);
  cursor: pointer;
}

.toggle {
  width: 24px;
  padding: 0;
  font-size: 10px;
  line-height: 16px;
}

.toggle.mute[aria-pressed="true"] {
  background: color-mix(in srgb, var(--warn) 25%, var(--raised));
  border-color: var(--warn);
  color: var(--warn);
}

.toggle.solo[aria-pressed="true"] {
  background: color-mix(in srgb, var(--info) 25%, var(--raised));
  border-color: var(--info);
  color: var(--info);
}

/* Eight strips a row need about 1300 px; below that, four. */
@media (max-width: 1400px) {
  .strips {
    grid-template-columns: repeat(4, minmax(0, 1fr));
  }
}

/* Too narrow for the totals beside the hub's name: they take the
   header's width. */
@media (max-width: 1400px) {
  .masthead {
    grid-template-columns: 1fr auto;
    grid-template-areas:
      "brand link"
      "ingest ingest"
      "tally tally"
      "tabs tabs";
  }

  .ingest {
    justify-content: start;
  }
}

@media (max-width: 1100px) {
  /* Too narrow for four columns: the figures wrap, each in its width. */
  .ingest {
    display: flex;
    flex-wrap: wrap;
  }

  /* One column: the table above the detail, each scrolling on its own,
     so the table keeps its sticky column headers. The table takes 45 %
     of the window's height and the detail the rest, whatever either
     holds, open or closed: the fleet growing or emptying (a daemon
     restart) moves neither pane, and the listening bar showing takes
     its room from the detail's foot alone. */
  .console {
    grid-template-columns: minmax(0, 1fr);
    grid-template-rows: minmax(0, 45dvh) minmax(0, 1fr);
  }

  .detail {
    border-left: 0;
    border-top: 1px solid var(--rule);
  }
}

/* A phone (the cards, src/view/cards.rs): under the header, and the bar while
   it plays, two panes would each be too short to read, so the cards and
   the detail scroll together in one pane, and selecting a card scrolls
   its detail into view. */
@media (max-width: 720px) {
  /* The node being listened to takes the bar's first line whole, so a
     longer name moves nothing beside it. */
  .listen-node {
    flex-basis: 100%;
  }

  .console {
    grid-template-rows: none;
    grid-auto-rows: max-content;
    overflow-y: auto;
    scrollbar-gutter: stable;
  }

  .detail {
    overflow-y: visible;
    scrollbar-gutter: auto;
  }

  /* Where the detail is not a pane of its own, the held note's room is
     kept while it is hidden, so its showing grows nothing. */
  .held-note.hidden {
    display: block;
    visibility: hidden;
  }

  /* The connection's pill keeps the width of its longest state, wider
     than a phone's room beside the hub's name, so it takes a line. */
  .masthead {
    grid-template-columns: minmax(0, 1fr);
    grid-template-areas:
      "brand"
      "link"
      "ingest"
      "tally"
      "tabs";
  }
}

@media (max-width: 700px) {
  .strips {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}

/* Touch: controls 32 px tall and at least 28 px wide, past WCAG 2.5.8's
   24 px, since a finger is far less precise than a mouse. */
@media (pointer: coarse) {
  button {
    min-height: 32px;
  }

  /* In the header's line: 24 px, WCAG 2.5.8's least. */
  .figure-button {
    min-height: 24px;
  }

  .play {
    width: 36px;
    height: 32px;
    font-size: 12px;
  }

  .strip {
    grid-template-columns: 28px minmax(24px, 1fr) auto auto auto;
  }

  .toggle {
    width: 28px;
  }

  .strips {
    gap: 8px 12px;
  }

  .volume input {
    height: 32px;
  }

  .tab {
    padding: 10px 16px;
  }
}

/* Records the page holds while asks for newer ones fail: the last ones
   received, grey under a note that says why and when it asks again. */
.records-stale {
  margin: 0 0 8px;
  padding: 6px 10px;
  background: color-mix(in srgb, var(--warn) 14%, var(--panel));
  color: var(--ink);
}

.stale-records .record {
  opacity: 0.6;
  filter: grayscale(1);
}

/* The Map tab (src/view/map.rs): the offline basemap with the radar's
   picture over it, and nothing beside it. */

.radar {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  grid-template-rows: minmax(0, 1fr);
  position: relative;
}

.radar-map {
  position: relative;
  overflow: hidden;
  min-height: 0;
  /* What shows where the basemap is not (loading, or its files missing). */
  background: var(--ground);
}

/* The map fills the pane and takes its own gestures. MapLibre's
   stylesheet makes its container `position: relative`; the doubled class
   outranks it. */
.radar-basemap.radar-basemap {
  position: absolute;
  inset: 0;
  overscroll-behavior: none;
}

/* What stands above the ground, drawn over the map by its projection
   (web/map.js); the map under it keeps every pointer event. */
.sm-map-air {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
}

/* The editor's pin, dragged on the map. */
.sm-map-pin {
  cursor: grab;
  filter: drop-shadow(0 1px 2px rgb(0 0 0 / 60%));
}

.sm-map-pin:active {
  cursor: grabbing;
}

/* MapLibre's own controls, in the page's colours. */
.radar-basemap .maplibregl-ctrl-group {
  background: color-mix(in srgb, var(--ground) 90%, transparent);
  border: 1px solid var(--rule);
  box-shadow: none;
}

.radar-basemap .maplibregl-ctrl-group button + button {
  border-top: 1px solid var(--rule);
}

.radar-basemap .maplibregl-ctrl-group button .maplibregl-ctrl-icon {
  filter: none;
}

.radar-basemap .maplibregl-ctrl-top-right {
  top: 40px;
}

.radar-basemap .maplibregl-ctrl-bottom-left {
  bottom: 28px;
}

.radar-basemap .maplibregl-ctrl-scale {
  background: color-mix(in srgb, var(--ground) 70%, transparent);
  border-color: var(--ink);
  color: var(--ink);
}

.radar-place {
  position: absolute;
  top: 8px;
  left: 50%;
  transform: translateX(-50%);
  margin: 0;
  padding: 6px 12px;
  border-radius: 4px;
  background: color-mix(in srgb, var(--ground) 90%, transparent);
  border: 1px solid var(--rule);
  pointer-events: none;
}

.radar-legend {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  padding: 4px 12px 6px;
  font-size: 12px;
  background: color-mix(in srgb, var(--ground) 75%, transparent);
}

/* Blocks read top to bottom, each set off by the same rule and gap. */
.radar-block {
  margin-bottom: 12px;
}

.radar-block h2 {
  font: 600 12px/1.4 var(--sans);
  color: var(--muted);
  text-transform: uppercase;
  letter-spacing: 0.04em;
  margin-bottom: 4px;
}

.radar-facts div {
  display: grid;
  grid-template-columns: 8ch minmax(0, 1fr);
  gap: 8px;
  line-height: 1.6;
}

.radar-facts dt {
  color: var(--muted);
}

.radar-node {
  display: grid;
  gap: 8px;
  align-items: center;
  line-height: 1.7;
  white-space: nowrap;
}

/* Per node: address, state, level, direction, peak, heading. */
.radar-node {
  grid-template-columns: 8ch 8ch 5ch minmax(9ch, 1fr) 5ch 11ch;
  gap: 5px;
  font: 12px/1.7 var(--mono);
}

button.radar-pick {
  width: 100%;
  padding: 0 4px;
  text-align: left;
  font: inherit;
  color: inherit;
  background: none;
  border: 1px solid transparent;
  border-radius: 3px;
  cursor: pointer;
}

button.radar-pick:hover {
  background: var(--raised);
}

button.radar-pick.selected {
  background: var(--raised);
  border-color: var(--info);
}

.radar-chip.holding,
.radar-chip.warn,
.radar-chip.poor {
  color: var(--warn);
}

.radar-chip {
  color: var(--muted);
  font-size: 10px;
  text-transform: uppercase;
}

.radar-head {
  color: var(--muted);
}

/* The compact panel's blocks. */
.radar-block-head {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 8px;
  margin: 10px 0 4px;
}

.radar-block-head h2 {
  margin: 0;
}

.radar-what {
  color: var(--muted);
}

.radar-row {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 6px;
  align-items: center;
  margin: 2px 0;
}

.radar-row-slider {
  grid-template-columns: 6ch minmax(0, 1fr) 11ch;
}

.radar-line {
  margin: 0 0 4px;
  white-space: normal;
}

/* A GNSS line naming a fault happening now: a count rising, a condition
   holding. Counts since boot and an ended session's read quiet. */
.radar-line.warn {
  color: var(--warn);
}

/* The selected node's GNSS at a glance (plan/gnss-glance.md): two lines
   that open its details. Line one: the fix's chip, solid while live and
   dashed when the receiver is off or silent, then 1σ, satellites and the
   corrections' age in fixed columns, and the faults' badge, the one filled
   mark. Line two: the receiver, its duty and session. Both keep one line
   whatever the state, cut, never wrapped. */
.gnss-glance > summary {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 8px 0 4px;
  color: var(--muted);
  cursor: pointer;
  list-style: none;
}

.gnss-glance p {
  margin: 0 0 4px;
  font-size: 12px;
}

.gnss-glance > summary::-webkit-details-marker {
  display: none;
}

.gnss-mark::before {
  content: "▸";
  color: var(--muted);
}

.gnss-glance[open] .gnss-mark::before {
  content: "▾";
}

.gnss-receiver .warn,
.gnss-fact.warn {
  color: var(--warn);
}

.gnss-facts {
  margin: 2px 0 6px calc(1.5ch + 6px);
}

.gnss-fact {
  display: block;
  overflow-wrap: anywhere;
}

.radar-modes {
  display: inline-flex;
}

.radar-mode {
  min-width: 6ch;
  border-radius: 0;
}

.radar-mode.selected {
  background: color-mix(in srgb, var(--info) 28%, var(--raised));
  border-color: var(--info);
  color: var(--ink);
}

.radar-slider {
  width: 100%;
  margin: 2px 0 0;
  accent-color: var(--info);
}

.radar-pick.absent {
  opacity: 0.55;
}

.radar-field {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  gap: 4px;
  align-items: center;
}

.radar-field input[type="number"] {
  width: 100%;
  min-width: 0;
  font: inherit;
  color: var(--ink);
  background: var(--raised);
  border: 1px solid var(--rule);
  border-radius: 3px;
  padding: 1px 4px;
}

.radar-error {
  color: var(--bad);
}

/* The Positioning block: the fleet's headline, its two controls, a row
   per node, the same marks as the map. */
.pos-mark {
  flex: none;
  width: 26px;
  height: 26px;
  display: block;
}

.pos-mark-small {
  width: 16px;
  height: 16px;
}

.pos-mark-empty {
  visibility: hidden;
}

.radar-node .pos-mark {
  width: 16px;
  height: 16px;
}

.pos-headline {
  margin: 2px 0;
  font: 600 14px/1.3 var(--sans);
  color: var(--ink);
}

.pos-warning {
  margin: 0;
  font: 600 13px/1.3 var(--sans);
  color: var(--pos-fault);
  display: none;
}

.pos-warning.shown {
  display: block;
}

.pos-actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  margin: 8px 0 6px;
}

.pos-action {
  font: 600 13px/1 var(--sans);
  padding: 7px 12px;
  color: var(--ink);
  background: var(--raised);
  border: 1px solid var(--rule);
  border-radius: 4px;
  cursor: pointer;
}

.pos-action:disabled {
  color: var(--muted);
  cursor: default;
  opacity: 0.7;
}

.pos-confirm-yes {
  border-color: var(--info);
}

.pos-confirm {
  flex: 1 1 100%;
  margin: 0;
  font: 13px/1.35 var(--sans);
  color: var(--ink);
}

.pos-line {
  display: none;
}

.pos-line.shown {
  display: block;
}

.pos-groups:empty {
  display: none;
}

.pos-list {
  margin-top: 4px;
}

/* Two lines, so nothing is cut off: what the node has, then what it is at. */
.pos-row {
  display: grid;
  grid-template-columns: 22px 9ch minmax(0, 1fr) auto;
  align-items: center;
  column-gap: 6px;
  padding: 4px;
}

button.pos-row.radar-pick {
  padding: 3px 4px;
}

.pos-name,
.pos-sure {
  font-family: var(--mono);
  font-size: 12px;
}

.pos-source {
  font-weight: 600;
}

.pos-sure {
  text-align: right;
}

/* A row's status; a delivery or a fault follows it and wraps with it. */
.pos-doing {
  grid-column: 2 / -1;
  color: var(--muted);
  font-size: 12px;
}

/* A small line that holds no room until it has something to say. */
.pos-small {
  margin: 0 0 4px;
  font-size: 12px;
}

.pos-small:empty {
  display: none;
}

.pos-note {
  color: var(--muted);
}

.pos-note.warn {
  color: var(--pos-fault);
}

.pos-editor {
  margin: 4px 0 6px;
  padding: 6px 8px;
  border: 1px dashed var(--rule);
  border-radius: 4px;
}

.pos-editor .pos-note {
  margin: 2px 0;
  font: 12px/1.35 var(--sans);
  white-space: normal;
  overflow: visible;
}

.pos-presets {
  flex-wrap: wrap;
  justify-content: flex-end;
}

.pos-preset {
  font-size: 12px;
  padding: 2px 6px;
}

.pos-coords {
  grid-template-columns: repeat(2, minmax(0, 1fr));
}

.pos-offset {
  grid-template-columns: repeat(4, minmax(0, 1fr));
}

.pos-offset select {
  width: 100%;
  color: var(--ink);
  background: var(--raised);
  border: 1px solid var(--rule);
  border-radius: 3px;
}

.pos-review {
  margin: 4px 0;
}

.pos-review dt {
  min-width: 10ch;
}

.radar-controls {
  position: absolute;
  top: 8px;
  right: 8px;
  display: flex;
  gap: 4px;
}

.radar-controls button {
  min-width: 2.2em;
}

.radar-controls button[aria-pressed="true"] {
  border-color: var(--select);
  color: var(--select);
}

/* The place under the pointer keeps its width, so the line beside it
   holds still. */
.radar-pointer {
  display: inline-block;
  min-width: 44ch;
  font-variant-numeric: tabular-nums;
}

.radar-starting {
  top: 42%;
  transform: translate(-50%, -50%);
  font-size: 14px;
}

.radar-map {
  overscroll-behavior: none;
}

/* A source: its direction, its range, its score, its harmonic evidence. */
.radar-source,
.radar-harmonic {
  display: grid;
  grid-template-columns: 17ch 11ch 11ch minmax(0, 1fr);
  gap: 8px;
  line-height: 1.7;
  white-space: nowrap;
}

.radar-harmonic {
  color: var(--warn);
}

.radar-harmonic.quiet {
  color: var(--muted);
}


/* The node picked on the map, and the way to its detail. */
.radar-picked {
  position: absolute;
  top: 8px;
  left: 8px;
  display: flex;
  gap: 12px;
  align-items: baseline;
  margin: 0;
  padding: 6px 12px;
  border-radius: 4px;
  background: color-mix(in srgb, var(--ground) 92%, transparent);
  border: 1px solid var(--rule);
  font-size: 13px;
}

.radar-picked a {
  color: var(--info);
}

.radar-place {
  top: 48px;
  max-width: min(60ch, 90%);
  text-align: center;
}

/* The Diagnosis tab (src/view/diagnosis.rs): the bar over the fleet with
   its three views, and the fleet's pane under it. */
.fleet-side {
  display: grid;
  grid-template-rows: auto minmax(0, 1fr);
  min-width: 0;
  min-height: 0;
}

.fleet-bar {
  display: flex;
  flex-wrap: wrap;
  gap: 6px 16px;
  align-items: center;
  justify-content: space-between;
  padding: 8px 12px;
  border-bottom: 1px solid var(--rule);
  background: var(--panel);
}

.fleet-bar .radar-mode {
  padding: 4px 14px;
}

/* Grey while nothing needs attention; the one warning carries a mark
   beside its words, in room kept for it. */
.fleet-attention {
  color: var(--muted);
  font-size: 13px;
  font-variant-numeric: tabular-nums;
}

.fleet-attention.warn {
  color: var(--warn);
  font-weight: 600;
}

.fleet-attention-mark {
  display: inline-block;
  width: 1.4em;
}

/* A view that is not the table: it scrolls on its own, like the table. */
.fleet-view {
  overflow: auto;
  scrollbar-gutter: stable;
  padding: 12px 16px 24px;
  font-size: 13px;
  line-height: 1.4;
}

.fleet-view .pos-list,
.fleet-view .radar-block {
  max-width: 1100px;
}

/* Positioning's rows, one line each where the pane is wide: mark, node,
   source, how sure, what it is doing. */
.fleet-view .pos-row {
  grid-template-columns: 26px 17ch 11ch 8ch minmax(0, 1fr);
  column-gap: 12px;
  border-bottom: 1px solid var(--rule);
  border-radius: 0;
}

.fleet-view .pos-doing {
  grid-column: auto;
  font-size: 13px;
}

.pos-head {
  color: var(--muted);
  font-size: 12px;
  padding: 3px 5px;
}

.fleet-view button.pos-row.radar-pick {
  padding: 6px 4px;
}

.fleet-view .pos-name,
.fleet-view .pos-sure {
  font-size: 13px;
}

.sound-view .radar-node {
  grid-template-columns: 17ch 9ch 6ch minmax(12ch, 16ch) 6ch minmax(11ch, 1fr);
  gap: 12px;
  font-size: 13px;
  border-bottom: 1px solid var(--rule);
  border-radius: 0;
}

.sound-view button.radar-pick {
  padding: 4px;
}

.sound-view .radar-block {
  margin-top: 16px;
}

.fleet-view h3,
.pos-section h3 {
  font: 600 12px/1.4 var(--sans);
  color: var(--muted);
  text-transform: uppercase;
  letter-spacing: 0.04em;
  margin: 0 0 6px;
}

@container (width < 620px) {
  .fleet-view .pos-row {
    grid-template-columns: 26px minmax(0, 1fr) auto auto;
  }

  .fleet-view .pos-doing {
    grid-column: 2 / -1;
  }

  .pos-head {
    display: none;
  }
}

.fleet-side {
  container-type: inline-size;
}

/* The detail's two halves, on tabs under the node's name. */
.detail-tabs {
  display: flex;
  flex-basis: 100%;
  gap: 2px;
  margin: 2px -14px -10px;
  padding: 0 10px;
}

.detail-tab {
  padding: 6px 12px;
  border: 0;
  border-bottom: 2px solid transparent;
  border-radius: 0;
  background: none;
  color: var(--muted);
}

.detail-tab.selected {
  color: var(--ink);
  border-bottom-color: var(--select);
}

/* A half's problem: a mark in room kept for it, so its coming moves no tab. */
.detail-tab-mark {
  display: inline-block;
  width: 1.3em;
  color: var(--warn);
  font-size: 11px;
  visibility: hidden;
}

.detail-tab-mark.shown {
  visibility: visible;
}

.detail-positioning {
  padding: 12px 14px 24px;
  font-size: 13px;
  line-height: 1.4;
}

.pos-section + .pos-section {
  margin-top: 16px;
  padding-top: 14px;
  border-top: 1px solid var(--rule);
}

.pos-section .radar-block-head {
  margin: 0 0 6px;
}

.pos-section .radar-block-head h3 {
  margin: 0;
}

.pos-section .radar-row-slider {
  grid-template-columns: minmax(0, 1fr) 16ch;
}

.gnss-faults {
  margin: 0;
  padding: 0;
  list-style: none;
}

.gnss-faults .warn {
  color: var(--pos-fault);
}

/* A phone: the fleet and the detail scroll together, so no view scrolls
   on its own. */
@media (max-width: 720px) {
  .fleet-side {
    display: block;
  }

  .fleet-view {
    overflow: visible;
  }
}

/* A view's column heads stand over its rows' cells: a row is a button,
   with a border and its padding. */
.fleet-view .pos-head {
  padding: 3px 5px;
  border-bottom: 1px solid var(--rule);
}

.sound-view .radar-head {
  padding: 0 5px;
  font-family: var(--sans);
  font-size: 12px;
}

.sound-view .radar-facts div {
  grid-template-columns: 16ch minmax(0, 1fr);
}

.pos-within {
  text-align: right;
}

/* The heads' tracks are in ch, so they take the rows' font size. */
.fleet-view .pos-head,
.sound-view .radar-head {
  font-size: 13px;
}

.sound-view .radar-head {
  font-family: var(--mono);
}

/* The sources a louder one hid stand under the fusion's own, a small head
   between the two runs of rows. */
.sound-view .radar-block h4 {
  margin: 10px 0 2px;
  color: var(--muted);
  font-size: 13px;
  font-weight: 500;
}

/* The page's password (src/view/lock.rs): the form three tabs show while
   the hub asks for it, and the control that locks them again. */

main.page-password {
  display: grid;
  place-content: start center;
  padding: 48px 16px;
}

main.page-password form {
  display: grid;
  gap: 12px;
  min-width: 280px;
  padding: 20px;
  background: var(--panel);
  border: 1px solid var(--rule);
  border-radius: 4px;
}

main.page-password h2 {
  margin: 0;
  font-size: 1.1rem;
}

main.page-password label {
  display: grid;
  gap: 4px;
  color: var(--muted);
}

main.page-password input {
  font: inherit;
  color: var(--ink);
  background: var(--ground);
  border: 1px solid var(--rule);
  border-radius: 3px;
  padding: 4px 8px;
}

.page-password-said {
  min-height: 1.4em;
  margin: 0;
  color: var(--bad);
}

.page-lock {
  position: fixed;
  right: 12px;
  bottom: 56px;
  margin: 0;
  z-index: 5;
}

/* The header's recording indicator (src/view/header.rs): a red dot that
   beats while the hub records, the recorder's pill and the time run. */

.rec {
  display: inline-flex;
  gap: 6px;
  align-items: center;
  font-size: 12px;
}

.rec-dot {
  width: 10px;
  height: 10px;
  border-radius: 50%;
  border: 1px solid var(--rule);
}

.rec.on .rec-dot {
  background: var(--bad);
  border-color: var(--bad);
  animation: rec-beat 1.2s ease-in-out infinite;
}

@keyframes rec-beat {
  50% {
    opacity: 0.35;
  }
}

@media (prefers-reduced-motion: reduce) {
  .rec.on .rec-dot {
    animation: none;
  }
}

/* A phone's header, compact (owner, 2026-11-12): the hub's name, the
   connection and the recorder on one line, then the tally and the tabs.
   The version and the ingest totals are left to wider screens, and the
   pills take their own word's width, not their longest's. */
@media (max-width: 720px) {
  .masthead {
    grid-template-columns: auto minmax(0, 1fr);
    grid-template-areas:
      "brand link"
      "tally tally"
      "tabs tabs";
    gap: 4px 10px;
    padding: 6px 10px 0;
  }

  .brand h1 {
    font-size: 14px;
  }

  .hub-version,
  .ingest {
    display: none;
  }

  .link {
    flex-wrap: wrap;
    gap: 2px 8px;
    justify-content: end;
    min-width: 0;
  }

  .link .pill-slot > .ghost {
    display: none;
  }

  .tally {
    gap: 3px;
    margin: 0 -10px;
  }

  .counts {
    gap: 2px 12px;
    padding: 0 10px;
    font-size: 11px;
  }

  .tabs {
    overflow-x: auto;
  }

  .tab {
    padding: 5px 9px 4px;
    font-size: 13px;
    white-space: nowrap;
  }
}
