/* munin-rs web UI — one embedded stylesheet, no external assets.
 * Compact is the standing principle: dense tables, no decorative
 * filler. */

:root {
  color-scheme: light;

  --fg: #1a1a1a;
  --bg: #ffffff;
  --muted: #666666;
  --rule: #d0d0d0;
  --bar-bg: #23425c;
  --bar-fg: #ffffff;
  --ok: #00801a;
  /* #8a5200: 6.39:1 on white — clears AA for the 14px bold state text
   * (#b36b00 measured 4.18:1, below AA; fixed 2026-08-13). */
  --warning: #8a5200;
  --critical: #cc0000;
  --unknown: #666666;
  /* the refresh ring, readable on the dark bar */
  --tick: #7fd1ff;
  --link: #0645ad;
  /* the same link, stepped back for the resolved/closed tables: still
     a link, still AA on the page ground (6.32:1), and measured rather
     than composited by a blanket opacity */
  --link-stepped: #2f5fa8;
  /* the visible edge of a control whose shape IS its border — the
     page-links pills. `--rule` is a table-density choice at 1.54:1;
     a control needs the 3:1 a non-text indicator is held to. */
  --control-line: #8f8f8f; /* 3.23:1 */
  --group-bg: #eef2f5;
  --zoom-marker: #ff0000;
  --zoom-band: rgba(0, 102, 179, 0.25);
  /* topbar accents: white over the bar, in both themes */
  --bar-line: rgba(255, 255, 255, 0.5);
  /* a green that reads on the bar in both themes ("all clear") */
  --bar-ok: #7fd97f;
  /* the topbar's exact rendered height (bargap.js measures the real
   * bar): every other sticky element hangs its `top` off this, flush,
   * so nothing is left uncovered between it and the topbar's own
   * bottom edge for scrolled content to show through. */
  --bar-h: 2.6em;
  /* --bar-h plus a breath, for anchor scroll-margin only: a resting
   * scroll position may reserve extra clearance for readability
   * because nothing needs to *paint* over that gap the way a sticky
   * element's neighbour does. */
  --bar-gap: calc(var(--bar-h) + 0.4em);
  /* the sticky column-header row's own height, reserved twice: once
   * under the bar so a sticky SECTION header lands below it, once in
   * the anchor scroll-margin below. One value, so a deep link and a
   * sticky row can never disagree about where the content starts. */
  --head-gap: 1.9em;
  --bar-track: rgba(255, 255, 255, 0.25);
  --bar-dim: rgba(255, 255, 255, 0.75);
  /* still the pre-2026-08-13 warning rgb: at 12% over white this washes
   * to #f6ede0, within 5/255 per channel of a wash mixed off #8a5200,
   * so the AA fix above did not need to move the tint too. */
  --warning-tint: rgba(179, 107, 0, 0.12);
  --critical-tint: rgba(204, 0, 0, 0.12);
  --banner-bg: rgba(204, 0, 0, 0.08);
  --banner-line: rgba(204, 0, 0, 0.3);
  /* The interim dark-mode treatment reinterprets the pinned graph
   * palette rather than re-rendering it: `invert(1) hue-rotate(180deg)`
   * over the PNG. Measured on the inverted canvas, the worst two curves
   * are FFCC00 → 2.29:1 and CCFF00 → 1.61:1 — still an improvement on
   * the four of twelve fields already under 3:1 on today's white
   * canvas, which is why the interim was accepted. The full
   * remeasurement belongs to the server-rendered-dark-graphs follow-up
   * (docs/TODO.md), which deletes this token, the `.dark-only`
   * machinery and the visible note together. */
  --graph-filter: none;
  --dark-only: none;
  /* the cue at a horizontally scrolling container's edge: a shadow the
   * content appears to slide under, painted only while there IS more
   * content that way (see `.figure-scroll` and friends below) */
  --scroll-shadow: rgba(0, 0, 0, 0.22);
  /* The field page's heatmap scale (spec §3): quantized into
   * HEAT_BUCKETS = 8 buckets, one class per bucket
   * (`web::figure::HeatScale`, whose edges are quantiles of the drawn
   * cells) — a sequential single-hue ramp (ColorBrewer "Oranges",
   * 8-class), low-to-high value. */
  --heat-0: #fff5eb;
  --heat-1: #fee6ce;
  --heat-2: #fdd0a2;
  --heat-3: #fdae6b;
  --heat-4: #fd8d3c;
  --heat-5: #f16913;
  --heat-6: #d94801;
  --heat-7: #8c2d04;
}

/* Dark mode, twice over: the media query so `auto` follows the reader's
 * OS, the attribute so an explicit choice wins in both directions. The
 * bodies are byte-identical and hold nothing but custom properties —
 * every theme-dependent rule lives once, below, reading its token
 * (pinned by tests/assets.rs). `:not([data-theme="light"])` is what
 * lets an explicit light choice beat an OS-dark preference; the two
 * dark selectors have equal specificity, so source order decides. The
 * media block's selector sits unindented at column 0, breaking with the
 * rest of this file's nesting, so its body keeps the same 2-space
 * property indentation as the attribute block below — the byte-identity
 * test compares raw text, and an indented selector would push its body
 * to 4 spaces and fail that test on whitespace, not values. */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
  color-scheme: dark;

  /* Measured contrast against --bg (2026-08-13); the state colours also
   * carry their ratio over the tint they are actually read on. */
  --fg: #e3e5e8; /* 13.96:1 */
  --bg: #17191c;
  --muted: #9aa1a9; /* 6.75:1 */
  --rule: #33383e;
  --bar-bg: #23262b;
  --bar-fg: #e3e5e8; /* 12.03:1 on --bar-bg */
  --ok: #4cbf68; /* 7.51:1 */
  --warning: #e0a33a; /* 7.94:1; 5.9:1 on --warning-tint */
  --critical: #f2686e; /* 5.86:1; 4.69:1 on --critical-tint */
  --unknown: #9aa1a9; /* 6.75:1 */
  --link: #79b8ff; /* 8.48:1 */
  --link-stepped: #6fa4dd; /* 6.74:1 */
  --control-line: #70777d; /* 3.88:1 */
  --group-bg: #212429;
  --zoom-marker: #ff7a7a;
  --zoom-band: rgba(121, 184, 255, 0.28);
  --warning-tint: rgba(224, 163, 58, 0.16);
  --critical-tint: rgba(242, 104, 110, 0.16);
  --banner-bg: rgba(242, 104, 110, 0.12);
  --banner-line: rgba(242, 104, 110, 0.45);
  --graph-filter: invert(1) hue-rotate(180deg);
  --dark-only: block;
  --scroll-shadow: rgba(0, 0, 0, 0.55);
  /* A REAL dark ramp, not an inversion of the light one (spec §3's
   * heatmap scale) — a teal sequential ramp tuned to read on --bg,
   * darkest-to-brightest low-to-high value. */
  --heat-0: #072022;
  --heat-1: #0b3a3e;
  --heat-2: #105456;
  --heat-3: #167a76;
  --heat-4: #1fa898;
  --heat-5: #4ecdb4;
  --heat-6: #8de8c8;
  --heat-7: #d4f8dc;
}
}

:root[data-theme="dark"] {
  color-scheme: dark;

  /* Measured contrast against --bg (2026-08-13); the state colours also
   * carry their ratio over the tint they are actually read on. */
  --fg: #e3e5e8; /* 13.96:1 */
  --bg: #17191c;
  --muted: #9aa1a9; /* 6.75:1 */
  --rule: #33383e;
  --bar-bg: #23262b;
  --bar-fg: #e3e5e8; /* 12.03:1 on --bar-bg */
  --ok: #4cbf68; /* 7.51:1 */
  --warning: #e0a33a; /* 7.94:1; 5.9:1 on --warning-tint */
  --critical: #f2686e; /* 5.86:1; 4.69:1 on --critical-tint */
  --unknown: #9aa1a9; /* 6.75:1 */
  --link: #79b8ff; /* 8.48:1 */
  --link-stepped: #6fa4dd; /* 6.74:1 */
  --control-line: #70777d; /* 3.88:1 */
  --group-bg: #212429;
  --zoom-marker: #ff7a7a;
  --zoom-band: rgba(121, 184, 255, 0.28);
  --warning-tint: rgba(224, 163, 58, 0.16);
  --critical-tint: rgba(242, 104, 110, 0.16);
  --banner-bg: rgba(242, 104, 110, 0.12);
  --banner-line: rgba(242, 104, 110, 0.45);
  --graph-filter: invert(1) hue-rotate(180deg);
  --dark-only: block;
  --scroll-shadow: rgba(0, 0, 0, 0.55);
  /* A REAL dark ramp, not an inversion of the light one (spec §3's
   * heatmap scale) — a teal sequential ramp tuned to read on --bg,
   * darkest-to-brightest low-to-high value. */
  --heat-0: #072022;
  --heat-1: #0b3a3e;
  --heat-2: #105456;
  --heat-3: #167a76;
  --heat-4: #1fa898;
  --heat-5: #4ecdb4;
  --heat-6: #8de8c8;
  --heat-7: #d4f8dc;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  /* room for the fixed top bar, which is out of flow: --bar-h is the
   * bar's measured height (bargap.js), so the gap tracks a bar that
   * wrapped onto a second row. The em fallback covers the no-JS case,
   * where the bar is one row by construction — refresh.js and theme.js
   * are what unhide the controls that make it wrap. */
  padding-top: var(--bar-h);
  font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue",
    Arial, sans-serif;
  font-size: 14px;
  line-height: 1.35;
  color: var(--fg);
  background: var(--bg);
}

/* Top bar: product name, breadcrumb, status-page nav. Centered, not
 * baseline-aligned: the brand and refresh widgets lead with an SVG,
 * whose flex "baseline" is its bottom edge, so baseline alignment
 * scatters the children vertically. Pinned to the viewport, so the
 * crumbs and the status-page nav stay reachable however far a long
 * page has scrolled — which is why the nav lives up here at all, a
 * host page's dozens of graphs having left any footer nav unreachable
 * in practice; the z-index lifts it over the zoom page's
 * absolutely-positioned markers and over the three sticky layers
 * inside a table (column headers 3, section rows 2, the board's host
 * column 1), any of which would otherwise paint through it.
 *
 * `fixed`, not `sticky`: a sticky bar is still a block in flow, as wide
 * as the body and no wider, so a page scrolled sideways by a wide board
 * carries the bar off to the left with it — sticky can only shift an
 * element WITHIN its containing block, and the bar already fills that.
 * Fixed pins both axes, which costs the flow slot `body`'s padding-top
 * gives back. */
.topbar {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 5;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.2em 0.75em;
  padding: 0.35em 0.8em;
  background: var(--bar-bg);
  color: var(--bar-fg);
}

.topbar a {
  color: var(--bar-fg);
  text-decoration: none;
}

.topbar a:hover {
  text-decoration: underline;
}

/* Brand mark: geometry lives in templates/brandmark.svg (the favicon's
 * inline twin); colours live here so the SVG carries none. */
.brand {
  display: inline-flex;
  align-items: center;
  gap: 0.45em;
}

.brandmark {
  width: 1.05em;
  height: 1.05em;
}

.brandmark .bm-line {
  fill: none;
  stroke: currentColor;
  stroke-width: 2.3;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.brandmark .bm-dot {
  fill: var(--tick);
}

.topbar .crumbs {
  /* the 9em basis is what lets the crumbs WRAP to their own bar row on
   * a narrow screen instead of ellipsizing into a meaningless stub */
  flex: 1 1 9em;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* The group path before the host crumb: context, not a link (groups
 * have no pages), so it steps back from the linked crumbs. */
.crumbs .crumb-group {
  color: var(--bar-dim);
}

/* Auto-refresh control: a depleting ring, the seconds left, and the
 * interval select. Shipped `hidden`; refresh.js reveals it, so a
 * no-JS reader never meets a dead widget (the <noscript> meta refresh
 * covers them instead). `.off` is the chosen-off state: the gauge
 * goes invisible but keeps its box, so the bar neither shrinks nor
 * shifts the select sideways when the timer is turned off. */
.refresh {
  display: flex;
  align-items: center;
  gap: 0.4em;
  white-space: nowrap;
  /* the bar's own gap puts this right against the status-page nav,
   * where the ring reads as part of that group; this separates the
   * page's state from the controls that act on it */
  padding-left: 0.9em;
}

/* The ring is a button: clicking it reloads now, whatever the interval
 * is doing — and it stays live while the timer is off, which is the
 * state every graph-bearing page starts in. */
.refresh-now {
  display: flex;
  align-items: center;
  gap: 0.4em;
  padding: 0;
  border: none;
  background: transparent;
  color: inherit;
  font: inherit;
  cursor: pointer;
}

.refresh[hidden] {
  display: none;
}

.refresh .ring {
  display: block;
  width: 18px;
  height: 18px;
}

.refresh .ring .track {
  fill: none;
  stroke: var(--bar-track);
  stroke-width: 3;
}

.refresh .ring .arc {
  fill: none;
  stroke: var(--tick);
  stroke-width: 3;
  stroke-linecap: round;
}

.refresh .left {
  font-size: 0.9em;
  font-variant-numeric: tabular-nums;
  color: var(--bar-dim);
  min-width: 3.2em;
  text-align: right;
}

/* "off" is a state, not a paused timer: the countdown goes away and the
 * arc empties, but the track stays drawn — the ring is the refresh-now
 * button, and hiding it would take manual refresh away from exactly the
 * pages that start with the timer off (every graph-bearing one). */
.refresh.off .left {
  visibility: hidden;
}

.refresh.off .arc {
  visibility: hidden;
}

.refresh.off .ring .track {
  stroke: var(--bar-dim);
}

.refresh select,
.themebtn {
  font: inherit;
  font-size: 0.9em;
  color: var(--bar-fg);
  background: transparent;
  border: 1px solid var(--bar-line);
  border-radius: 3px;
  padding: 0.05em 0.25em;
}

/* "✓ all clear" replaces the three zero counters on a clean fleet —
 * the healthy state should be the easiest to spot, not the hardest. */
.topbar .allclear {
  color: var(--bar-ok);
}

/* Keyboard focus, once, for everything that takes it. The UA default
 * is a thin dark ring, which on the dark top bar is very nearly
 * invisible — so the bar's own controls take the bar's foreground
 * instead. `:focus-visible` rather than `:focus`, so a mouse click does
 * not leave a ring behind; the sidebar's anchor rows deliberately keep
 * their `:focus` styling, which is what marks the anchor last followed. */
:where(a, button, select, input, summary, [tabindex]):focus-visible {
  outline: 2px solid var(--link);
  outline-offset: 2px;
}

.topbar :focus-visible {
  outline-color: var(--bar-fg);
}

/* The first thing a keyboard reader meets: a way past a top bar and, on
 * the host page, a sidebar of controls, to the content itself. Off
 * screen until focused, then placed over the bar it skips. */
/* The no-JS reader's pause button. Only ever inside <noscript>, so a
   scripted page never shows it — there the interval <select> is the
   mechanism. */
.noscript-refresh {
  margin: 0;
  padding: 0.4em 1rem;
  background: var(--group-bg);
  border-bottom: 1px solid var(--rule);
  font-size: 0.9em;
  color: var(--muted);
}

/* Fixed and above the bar: it is the first thing a keyboard reader
 * reaches, and the bar it shares the top edge with paints at z-index 5. */
.skip {
  position: fixed;
  left: -100vw;
  top: 0;
  z-index: 6;
  padding: 0.4em 0.8em;
  background: var(--bg);
  color: var(--link);
}

.skip:focus {
  left: 0;
}

/* Visually hidden, present for the document outline and screen
 * readers: every page carries an h1 even where the compact layout
 * shows none.
 *
 * `fixed`, not `absolute`: with no positioned ancestor an absolute box's
 * containing block is the initial one, so it escapes the nearest scroll
 * clip and its static position — the far right of a wide table — becomes
 * document scrollable overflow. The board's per-column notes slid the
 * whole page sideways into blank space that way. A fixed box contributes
 * no scrollable overflow at all; both are out of flow and clipped to
 * nothing here, so nothing else changes. */
.vh {
  position: fixed;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* The widest of its three states ("light"), reserved up front so the
 * bar does not shift sideways on every cycle of the toggle. */
.themebtn {
  min-width: 3.6em;
  text-align: center;
}

/* The options render in the OS popup, off the dark bar: restore the
 * page's own colours or they inherit unreadably. */
.refresh select option {
  color: var(--fg);
  background: var(--bg);
}

/* Touch: the topbar controls are far under the 24px comfortable
 * target at their mouse-sized padding. */
@media (pointer: coarse) {
  .refresh-now,
  .refresh select,
  .themebtn {
    padding: 0.35em 0.5em;
  }
}

main,
h1,
p {
  margin: 0.5em 0.8em;
}

/* Spec: "the [board] table scrolls horizontally if an installation has
 * unusually many [categories]" — no extra wrapper markup needed, `main`
 * is every page's one content container. */
main {
  overflow-x: auto;
}

h1 {
  font-size: 1.25em;
}

h2 {
  font-size: 1.05em;
  margin: 0.8em 0 0.2em;
}

/* A host page is one long run of thumbnails; at 1.05em bold the
 * category heading barely registered as you scrolled past it. A rule
 * across the content column makes the boundary a boundary — the
 * sidebar's anchors are how you jump, this is how you know where you
 * landed. */
/* …and it sticks under the top bar, so a reader ten thumbnails deep
 * still knows which category they are in. The inline margin becomes
 * padding: a sticky heading has to paint the full content width or the
 * graphs scroll through the 0.8em gutters beside it. Where
 * `position: sticky` is not supported the declaration is dropped and
 * the heading simply scrolls, which is what it did before. */
.host-body h2 {
  position: sticky;
  top: var(--bar-h);
  z-index: 1;
  margin: 1.4em 0 0.3em;
  padding: 0.15em 0.8em 0.2em;
  background: var(--bg);
  border-bottom: 1px solid var(--rule);
  font-size: 1.15em;
  letter-spacing: 0.02em;
}

.host-body section:first-of-type h2 {
  margin-top: 0.4em;
}

a {
  color: var(--link);
}

/* Dense table rules, shared by the board, problems and field tables.
 * The inline margin matches `p`'s, so a table's left edge lines up
 * with the text above it instead of outdenting by one step. */
table {
  border-collapse: collapse;
  margin: 0.4em 0.8em;
}

th,
td {
  padding: 0.15em 0.6em;
  border-bottom: 1px solid var(--rule);
  text-align: left;
  vertical-align: baseline;
}

/* The board's load sparkline is several lines tall, and an inline image
 * sits on the baseline, so baseline alignment drags every other cell in
 * the row down to the chip's bottom edge. Centre the board's cells
 * against it instead. */
table.board th,
table.board td {
  vertical-align: middle;
}

/* Nothing in these three tables is prose, so a wrapping cell would mean a
 * column had silently grown too wide; `main`'s overflow-x scrolls instead.
 * Named per table rather than blanket `th, td` so the one prose column
 * below opts out by being absent, not by fighting an override. */
table.board th,
table.board td,
table.problems th,
table.problems td,
table.anomalies th,
table.anomalies td,
table.fields th,
table.fields td {
  white-space: nowrap;
}

/* The problems page is two tables, and the reader compares across the
 * gap between them: the same host, the same service, the same field,
 * open above and resolved below. Sized to its own rows each table picks
 * its own grid, so every shared column steps sideways halfway down the
 * page. Both therefore declare the same `<col>` classes and take these
 * widths (pinned by tests/assets.rs). Auto table layout treats a column
 * width as a floor, not a cap — a name wider than its column still
 * widens it, and `main` still scrolls on a narrow screen, so nothing is
 * ever clipped; `table-layout: fixed` would buy exactness at that price
 * and these tables are built on content-driven widths. */
table.problems col.c-host {
  width: 18ch;
}

table.problems col.c-service {
  width: 14ch;
}

table.problems col.c-field {
  width: 14ch;
}

table.problems col.c-state {
  width: 11ch;
}

table.problems col.c-value {
  width: 9ch;
}

table.problems col.c-threshold {
  width: 11ch;
}

/* The exception: a field's info is a sentence. It wraps, to a measure
 * capped so the numeric columns keep the eye's left edge — without this
 * one long info pushes the whole table behind a horizontal scroll. */
table.fields td.info {
  white-space: normal;
  /* This table already scrolls horizontally when its numeric columns do
   * not fit. Letting the prose column collapse to its longest single word
   * does not avoid that scroll; it only turns a short description into a
   * stack of one-word lines and makes every field row needlessly tall. */
  min-width: 24ch;
  max-width: 46ch;
  color: var(--muted);
}

/* The plugin's own extinfo prose, set off from the static field info
 * (and from the field label on the problems page) it sits beside. */
.extinfo {
  color: var(--muted);
  font-style: italic;
}

/* Which window min/avg/max cover, and the graph's unit: the same table
 * renders over two different windows on the service and zoom pages,
 * and this line is what says which. Bottom-side, so the header row
 * stays first for readers and the sticky rule above. */
table.fields caption {
  caption-side: bottom;
  text-align: left;
  padding: 0.2em 0.6em;
  color: var(--muted);
  font-size: 0.92em;
}

th {
  border-bottom-width: 2px;
}

/* Every column of numbers, header included: a right-aligned column
 * under a left-aligned heading reads as two columns. */
td.num,
th.num {
  text-align: right;
  font-variant-numeric: tabular-nums;
}

/* Wide viewports: the tables fit, so `main` need not be a scroll
 * container — and that is exactly what lets the sticky header row
 * work (any overflow!=visible ancestor would capture the sticky
 * against its own, never-scrolling scrollport). Narrow viewports keep
 * `main`'s horizontal scroll and trade the sticky row away. A
 * pathologically wide board on a wide screen scrolls at page level. */
@media (min-width: 64em) {
main {
  overflow-x: visible;
}

table.board thead th,
table.problems thead th,
table.anomalies thead th,
table.corr thead th,
table.fields thead th {
  position: sticky;
  top: var(--bar-h);
  z-index: 3;
  background: var(--bg);
}

/* The section-header rows stick too, one header-row height further
 * down: the board's group rows, the problems page's state headings and
 * the anomalies page's event banners all say what the rows under them
 * are, and a reader who has scrolled past one is reading rows with no
 * heading. `--head-gap` is the same reserve the anchor scroll-margin
 * spends, so a deep link and a sticky pair cannot disagree.
 * The three sticky layers inside a table are stated as numbers, not
 * left to DOM order: column headers (3) over section rows (2) over the
 * board's sticky host column (1). With `z-index: auto` every one of
 * them would paint by tree order, which puts the row that scrolled
 * last on top of the header pinning it. */
table.board tbody tr.group th,
table.problems tbody tr.group th,
table.anomalies tbody tr.group th {
  position: sticky;
  top: calc(var(--bar-h) + var(--head-gap));
  z-index: 2;
}
}

/* Each row's natural key is a row header by markup (scope=row) — the
 * host on the board, the field in the other three — but styled like the
 * data cell it replaced: `th` is bold, and its 2px rule below is the
 * *column*-header rule, which on a body cell draws one heavy line down
 * the table. (The board's host column predates the class and keeps that
 * rule, unchanged.) */
table.board .rowhead,
table.problems .rowhead,
table.anomalies .rowhead,
table.corr .rowhead,
table.fields .rowhead {
  font-weight: normal;
  text-align: left;
}

table.problems .rowhead,
table.anomalies .rowhead,
table.corr .rowhead,
table.fields .rowhead {
  border-bottom-width: 1px;
}

/* The host column survives horizontal scrolling — under both sticky
 * header layers above it (see the z-index note there). */
table.board .rowhead {
  position: sticky;
  left: 0;
  z-index: 1;
  background: var(--bg);
}

/* Row highlight for the three dense tables: ten columns lose the eye.
 * Tinted state cells (warning, critical) keep their tint — it carries
 * meaning the wash would cover, and their measured contrast is against
 * that tint over --bg, not over a wash slid underneath it. The untinted
 * ones (ok, unknown) have no background of their own, so excluding them
 * left a hole in the hovered row; they take the wash like any other
 * cell. Written as "state cell without a tint" rather than a list of
 * the two states, so a further untinted state joins by construction. */
table.board tbody tr:hover td:not(.state),
table.board tbody tr:hover .rowhead,
table.board tbody tr:hover td.state:not(.warning):not(.critical),
table.problems tbody tr:hover td:not(.state),
table.problems tbody tr:hover .rowhead,
table.problems tbody tr:hover td.state:not(.warning):not(.critical),
table.anomalies tbody tr:hover td,
table.anomalies tbody tr:hover .rowhead,
table.corr tbody tr:hover td,
table.corr tbody tr:hover .rowhead,
table.fields tbody tr:hover td:not(.state),
table.fields tbody tr:hover .rowhead,
table.fields tbody tr:hover td.state:not(.warning):not(.critical) {
  background: var(--group-bg);
}

/* Board: one subheader row per group, shaded; nested groups indent the
 * label cell. Depth beyond depth6 keeps depth6's indent — deeper
 * nesting than that is not a configuration we expect to see. */
tr.group th {
  background: var(--group-bg);
  font-weight: bold;
  border-bottom: none;
  padding-top: 0.6em;
}

/* "group" before the name: the row is a heading for the hosts under it,
 * which a bare name never said. Muted and light, so the name still
 * carries the row. */
.grouplabel {
  color: var(--muted);
  font-weight: normal;
  font-size: 0.9em;
}

/* Problems: one subheader row per state section, the board's group
 * shading with the state's own colour on the word — the one real
 * WARNING stays findable above an unknown-flood. */
tr.state-head.warning th {
  color: var(--warning);
}

tr.state-head.critical th {
  color: var(--critical);
}

tr.state-head.unknown th {
  color: var(--unknown);
}

/* Anomalies: the open group header borrows the warning colour (a live
 * excursion is a thing to look at); the closed ring sits below it,
 * visually stepped back rather than shouting equally. The problems
 * page's resolved section steps back the same way: what is over is a
 * record, not a call for attention. */
tr.state-head.active th {
  color: var(--warning);
}

/* Stepped back with tokens rather than `opacity: 0.7`. The blanket
   opacity composited every colour inside these tables — muted text,
   links, WARNING and CRITICAL state cells, the caption — down past the
   ratios the token comments record, to as low as 3.03:1 in light and
   3.46:1 in dark. A contrast budget has to be stated somewhere it can
   be measured, not multiplied behind the tokens' backs. */
table.anomalies.closed,
table.anomalies.adapted,
table.problems.resolved {
  color: var(--muted);
}

table.anomalies.closed a,
table.anomalies.adapted a,
table.problems.resolved a {
  color: var(--link-stepped);
}

table.anomalies.closed caption,
table.anomalies.adapted caption,
table.problems.resolved caption {
  caption-side: top;
  text-align: left;
  padding: 0.6em 0.6em 0.2em;
  color: var(--muted);
  font-weight: bold;
}

/* The seen cells and the closed ring's closed-at cell tick once a
 * second (age.js), and "9m 59s ago" is not as wide as "10m 0s ago":
 * reserve the widest sub-day age ("59m 59s ago") up front so a tick
 * never shifts the columns — the themebtn's reserve-the-widest-state
 * idiom. Day-class ages can outgrow the reserve, but they change once
 * an hour, not once a second. */
table.anomalies td.seen,
table.anomalies td.anomaly-state.closed,
table.anomalies td.anomaly-state.adapted,
table.problems.resolved td.seen {
  min-width: 11ch;
  font-variant-numeric: tabular-nums;
}

/* Which way the excursion went. `peak σ` is an absolute distance, so
   the column beside it said nothing about direction. */
.dir {
  color: var(--muted);
  font-size: 0.85em;
}

/* The bounded impact rank. Digits line up column-wise like every other
   number in these tables. */
table.anomalies td.effect {
  font-variant-numeric: tabular-nums;
}

.rank-select {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.45rem;
  margin: 0.65rem 0;
  color: var(--muted);
}

/* Pill links, the same affordance the page-link nav uses: the selected
   order carries a filled background AND weight, so it is not colour
   alone that says which one is live. */
.rank-select a {
  padding: 0.2em 0.75em;
  border: 1px solid var(--control-line);
  border-radius: 999px;
  text-decoration: none;
}

.rank-select a:hover {
  background: var(--group-bg);
}

.rank-select a[aria-current] {
  background: var(--group-bg);
  color: var(--fg);
  font-weight: 600;
}

/* An excursion the weekly check never confirmed — it opened for want of
   weekly history, which fails open. The WORD carries the mark, with a
   rule down the cell's edge to make it scannable: a colour alone would
   say nothing in print, in mono, or to a colour-blind reader. */
table.anomalies .flag {
  color: var(--muted);
  font-size: 0.85em;
  font-style: italic;
}

table.anomalies td.unconfirmed {
  border-left: 2px solid var(--rule);
}

/* A row judged by a rule no longer in force: the same edge treatment
   as `unconfirmed` — structural, not colour — and its `flag` links to
   the vintage list under the closed table. */
table.anomalies td.earlier-rule {
  border-left: 2px solid var(--rule);
}

.vintages {
  color: var(--muted);
  font-size: 0.9em;
  margin: 0.5em 0.8em;
  padding-left: 1.5em;
}

/* One host's fields whose excursions began in the same detector row are
   one event, not N problems: a banner names the group and its member
   rows carry a rule down their left edge tying them to it. Both are
   structural rather than coloured, so the grouping survives print and a
   mono reading — and without JavaScript: the page's one script sorts
   columns and nothing else, so the grouping stands with it off. */
tr.group.event th {
  color: var(--muted);
  font-weight: normal;
  font-size: 0.9em;
  padding-top: 0.4em;
}

table.anomalies tbody tr.grouped > *:first-child {
  border-left: 3px solid var(--rule);
}

/* A collapsed flapping run's size, beside the state word: the pagelinks
 * count bubble, muted — resolved rows are a record, not a call for
 * attention. */
table.problems.resolved .count {
  display: inline-block;
  font-variant-numeric: tabular-nums;
  min-width: 1.5em;
  padding: 0.05em 0.35em;
  border-radius: 999px;
  background: var(--group-bg);
  color: var(--muted);
  text-align: center;
  font-weight: bold;
  font-size: 0.85em;
}

.anomaly-state.closed {
  color: var(--muted);
}

.anomaly-state.adapted {
  color: var(--muted);
}

/* The service page's deep link to /anomalies: header and field-row
 * glyphs both in the field-state warning colour, so an active anomaly
 * reads as the same "needs a look" signal as a breached WARNING. */
.anomaly-glyph {
  color: var(--warning);
  text-decoration: none;
}

.anomaly-glyph:hover {
  text-decoration: underline;
}

/* The field table's deep link to the per-field page: muted, unlike the
 * anomaly glyph beside it, since it names no problem of its own. */
.field-glyph {
  color: var(--muted);
  text-decoration: none;
}

.field-glyph:hover {
  text-decoration: underline;
}

tr.depth1 th {
  padding-left: 1.8em;
}

tr.depth2 th {
  padding-left: 3em;
}

tr.depth3 th {
  padding-left: 4.2em;
}

tr.depth4 th {
  padding-left: 5.4em;
}

tr.depth5 th {
  padding-left: 6.6em;
}

tr.depth6 th {
  padding-left: 7.8em;
}

td.nodata {
  color: var(--muted);
  font-style: italic;
}

.stale {
  color: var(--critical);
  font-weight: bold;
}

/* The host page's per-thumbnail "no answer since" marker: the same
 * statement the service page's banner makes, at caption size. Muted
 * rather than alarming — the graph beside it is real data, only older
 * than the poll that drew the page. */
.silent {
  color: var(--muted);
  font-style: italic;
}

/* The board's `update no` marker: informational, not an alarm. */
.nopoll {
  color: var(--muted);
  font-style: italic;
}

p.clean {
  color: var(--ok);
}

/* The problems page scoped to one host: what it is showing, and the way
 * back. Without it a short table reads as a quiet fleet. */
p.scoped {
  color: var(--muted);
}

/* The status-page nav, seated in the sticky top bar: a host page draws
 * dozens of graphs, and a nav at the foot of that column is a nav
 * nobody scrolls to. Its own flex row, so the four stay together when
 * the bar wraps to a second line rather than scattering among the
 * crumbs and the refresh control. Plain links, not the pills a page
 * body would use: the bar's idiom is text, and a pill drawn in the
 * page's `--control-line` would be invisible on `--bar-bg`. */
.topbar nav.pagelinks {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.2em 0.75em;
}

/* The status page you are already on, marked by weight — the bar has no
 * room for a background swatch, and it underlines on hover already. */
.topbar nav.pagelinks a[aria-current] {
  font-weight: 600;
  text-decoration: underline;
}

/* A 404 that resolved a host offers it, so a mistyped service is one
   click from where the reader meant to be rather than a trip via the
   board. A plain wrapping row: on a page whose only other content is
   one sentence, the offers need no chrome to be found. */
.notfound-links {
  display: flex;
  flex-wrap: wrap;
  gap: 0.6em 1.2em;
  margin: 1em 0.8em;
}

p.notfound {
  color: var(--muted);
}

/* State cells (board worst-state, problems row state): the same
 * :root tokens as every other state colour on the page (`.ok`,
 * `.warning`, …), so "OK" reads as the same green everywhere — plus a
 * background tint on warning/critical so a scanned column reads at a
 * glance. */
td.state {
  font-weight: bold;
}

/* The board's state cell links to the problems page: the cat-anchor
 * treatment (colour inherited, underline only on hover) keeps the
 * chip reading as a state until pointed at. */
td.state a {
  color: inherit;
  text-decoration: none;
}

td.state a:hover {
  text-decoration: underline;
}

td.state.ok {
  color: var(--ok);
}

td.state.warning {
  color: var(--warning);
  background: var(--warning-tint);
}

td.state.critical {
  color: var(--critical);
  background: var(--critical-tint);
}

td.state.unknown {
  color: var(--muted);
}

/* Field / host states. */
.ok {
  color: var(--ok);
}

.warning {
  color: var(--warning);
  font-weight: bold;
}

.critical {
  color: var(--critical);
  font-weight: bold;
}

.unknown {
  color: var(--unknown);
}

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

/* The interim dark-mode graph treatment: the PNGs are rendered light by
 * rrdtool, so dark mode inverts them in CSS until the renderer itself
 * learns the theme. Canvas, grid and axis text invert
 * cleanly; curve colours do not survive exactly, which is why every
 * graph-bearing page says so in dark mode. Layout stays with each
 * surface's own rule — this one is a hook and nothing else. It covers
 * the board sparkline too: a `spark` opt-out once left a white slab on
 * the dark board. */
img.graph,
svg.graph {
  filter: var(--graph-filter);
}

/* Host/service page banner: unreachable-since notice under the top
 * bar — the page still renders from last-known meta and RRDs. The
 * service page's "no answer since" notice wears the same class: it is
 * the same statement, made about one plugin instead of a whole node. */
.banner {
  margin: 0 0.8em 0.5em;
  padding: 0.4em 0.6em;
  border-radius: 3px;
}

.banner.unreachable {
  overflow-wrap: anywhere;
  color: var(--critical);
  background: var(--banner-bg);
  border: 1px solid var(--banner-line);
}

/* A poll failure, wherever it is shown (the outage banner and the host
 * sidebar's `last failure` parcel include the same partial). The kind
 * leads, the subject and detail follow, and a schema mismatch lays its
 * differences out as the diff they are instead of as a paragraph — the
 * shape the rail could never hold. */
.fail-kind {
  font-weight: bold;
}

.fail-subject {
  overflow-wrap: anywhere;
}

.fail-detail {
  overflow-wrap: anywhere;
}

.banner .fail-subject,
.banner .fail-detail {
  color: var(--fg);
}

/* The difference table: keys left, expected and found beside them.
 * Compact enough for the 12em rail and legible at banner width. */
table.fail-diffs {
  margin: 0.3em 0 0;
  border-collapse: collapse;
  font-size: 0.92em;
  font-variant-numeric: tabular-nums;
}

table.fail-diffs th,
table.fail-diffs td {
  padding: 0 0.5em 0 0;
  border-bottom: none;
  text-align: left;
  vertical-align: baseline;
  font-weight: normal;
  overflow-wrap: anywhere;
}

table.fail-diffs .want {
  color: var(--muted);
  text-align: right;
}

table.fail-diffs .got {
  color: var(--fg);
}

.banner table.fail-diffs .got {
  color: var(--critical);
}

table.fail-diffs .fail-note {
  color: var(--muted);
  font-style: italic;
}

/* The cap said out loud: a truncation that does not name itself is a
 * silent drop. */
.fail-more {
  margin: 0.2em 0 0;
  color: var(--muted);
  font-size: 0.9em;
}

p.nodata {
  color: var(--muted);
  font-style: italic;
}

p.info {
  color: var(--muted);
}

/* Host page: content left, an always-on sidebar right — the service
 * filter, the category anchors, a way back up, and this page's footer
 * (base.html's own is suppressed here; templates/pagefoot.html is the
 * one definition both seats include). `overflow-x: visible` is
 * load-bearing: the `auto` every other page keeps makes `main` a
 * scroll container, and the sidebar would stick to that instead of the
 * viewport — the host page has no wide tables to scroll anyway. */
main.host {
  overflow-x: visible;
  display: flex;
  align-items: flex-start;
  gap: 0.4em;
  /* wrap plus the full-width basis on `.dark-only` is what keeps
   * base.html's trailing note a line under the content instead of a
   * third column beside the rail; the two flex items above it both
   * have a fixed basis, so nothing else can reach the second line */
  flex-wrap: wrap;
}

.host-body {
  flex: 1;
  min-width: 0;
}

.host-side {
  position: sticky;
  top: var(--bar-h);
  flex: 0 0 12em;
  max-height: calc(100vh - 3.4em);
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: 0.6em;
  border-left: 1px solid var(--rule);
  /* the right padding is outline room: overflow-y makes this box clip
   * horizontally too, and without it the filter input's focus ring is
   * cut off at the box's right edge */
  padding: 0.5em 0.4em 0.5em 0.8em;
}

/* The filter box ships `hidden` (sidebar.js reveals it), same contract
 * as the refresh widget. */
.svc-search {
  position: relative;
  margin: 0;
}

.svc-search input {
  font: inherit;
  width: 100%;
  color: var(--fg);
  background: var(--bg);
  border: 1px solid var(--rule);
  border-radius: 3px;
  padding: 0.2em 1.6em 0.2em 0.5em;
}

/* Our own clear button covers every browser, so the WebKit-only
 * native one would be a second × on half of them. */
.svc-search input::-webkit-search-cancel-button {
  display: none;
}

.svc-clear {
  position: absolute;
  top: 50%;
  right: 0.3em;
  transform: translateY(-50%);
  border: none;
  background: transparent;
  color: var(--muted);
  font: inherit;
  padding: 0 0.25em;
  cursor: pointer;
}

.svc-clear:hover {
  color: var(--fg);
}

/* Category anchors, accent-stripe style (owner's pick, 2026-08-17):
 * flat rows, count right, a link-coloured stripe and group tint on the
 * category in view (`.here`, toggled by sidebar.js as the page
 * scrolls), the row under the pointer, and the anchor last followed —
 * focus persists after the click, which is what keeps a no-JS reader's
 * stripe meaning "where I just jumped". */
nav.anchors {
  display: flex;
  flex-direction: column;
  gap: 0.25em;
  margin: 0;
  /* Separates the control above from the navigation below — the same
   * rule the scan-fact parcels carry, so the rail reads as three
   * sections rather than one run. Rendered only when there is a
   * category to head (the template omits the whole nav otherwise). */
  border-top: 1px solid var(--rule);
  padding-top: 0.5em;
}

nav.anchors a {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 0.5em;
  padding: 0.1em 0.5em;
  border-left: 3px solid transparent;
  color: var(--fg);
  text-decoration: none;
}

/* `display` set here is author-origin and beats the UA's
   `[hidden] { display: none }`, so `sidebar.js` hiding an emptied
   category row had no visual effect at all — the row stayed, recounted
   to 0. Same pairing `.refresh[hidden]` above already carries; any
   selector that sets `display` on an element the templates can ship
   `hidden` needs one (pinned in tests/assets.rs). */
nav.anchors a[hidden] {
  display: none;
}

nav.anchors a:hover,
nav.anchors a:focus,
nav.anchors a.here {
  border-left-color: var(--link);
  background: var(--group-bg);
  color: var(--link);
}

nav.anchors a .count {
  color: var(--muted);
  font-size: 0.85em;
  font-variant-numeric: tabular-nums;
}

.totop {
  margin: 0;
  font-size: 0.9em;
}

/* The host's scan facts, parcelled: label over value(s), one parcel
 * per fact — the sidebar is too narrow for the board-style one-liner. */
.scaninfo {
  margin: 0.4em 0 0;
  padding: 0.4em 0 0;
  border-top: 1px solid var(--rule);
  font-size: 0.92em;
}

.scaninfo dt {
  color: var(--muted);
  margin-top: 0.35em;
}

.scaninfo dt:first-child {
  margin-top: 0;
}

.scaninfo dd {
  margin: 0;
  font-variant-numeric: tabular-nums;
}

/* Plugin names and error text are unbreakable tokens; the rail is
 * narrow. */
.scaninfo dd.missing,
.scaninfo dd.slowest,
.scaninfo dd.failmsg {
  overflow-wrap: anywhere;
}

/* The node info block sits atop the full-width host-body, not the
 * narrow rail, so it gets the board-style one-liner the comment above
 * describes for .scaninfo: label and value flow inline, wrapping at
 * the ` · ` separator instead of stacking one fact per line. */
.systeminfo {
  margin: 0 0 0.8em;
  padding: 0;
  border-top: none;
}

.systeminfo dt,
.systeminfo dd {
  display: inline;
  margin: 0;
}

.systeminfo dt::after {
  content: ": ";
}

.systeminfo dd:not(:last-of-type)::after {
  content: " · ";
  color: var(--muted);
}

/* The wire name: what node-side tooling (munin-run) calls this
 * service, shown when munging changed it in the URL. */
.rawname {
  color: var(--muted);
}

/* Spec-build warnings for this graph — on the page whose render they
 * degraded, not only in the log. */
.buildwarn {
  margin: 0.4em 0.8em;
  padding: 0.3em 0.8em;
  color: var(--warning);
  background: var(--warning-tint);
}

.buildwarn p,
.buildwarn ul {
  margin: 0.2em 0;
}

.buildwarn ul {
  padding-left: 1.4em;
}

/* The sidebar seat of the page footer: stacked and compact, the same
 * two spans as everywhere else. */
.host-side .pagefoot {
  flex-direction: column;
  gap: 0.2em;
  margin: 0.4em 0 0;
  padding: 0.4em 0 0;
}

/* Jumping to a category must land its heading below the sticky bar,
 * not under it: scroll-margin reserves the bar's height (plus a
 * breath) at the top of every in-page anchor target. */
/* Anything a fragment can land on clears the sticky bar. Rows carry
   ids too — every anomaly row does, and that is what the ⚠ glyphs link
   to — and without this the browser scrolls the row flush to y=0, where
   the bar paints straight over it. The extra covers the sticky `thead`
   that sits below the bar on wide screens. */
[id] {
  scroll-margin-top: calc(var(--bar-gap) + var(--head-gap));
}

section[id],
main[id] {
  scroll-margin-top: var(--bar-gap);
}

h2 a.cat-anchor {
  color: inherit;
  text-decoration: none;
}

h2 a.cat-anchor:hover {
  text-decoration: underline;
}

/* The host page's window control: the same preset row the zoom
 * toolbar carries, on the page that draws every graph at once. Plain
 * links, so it works with JavaScript off. */
.periodbar {
  margin: 0.3em 0.8em 0.6em;
  font-size: 0.92em;
}

.periodbar-label {
  margin-right: 0.5em;
  color: var(--muted);
}

.periodbar a {
  margin-right: 0.75em;
}

/* The window on screen: bold, not link-coloured — "you are here", not
 * "go here". The zoom toolbar's preset rule, said the same way. */
.periodbar a.active {
  color: var(--fg);
  font-weight: bold;
  text-decoration: none;
}

.thumbs {
  display: flex;
  flex-wrap: wrap;
  gap: 0.8em 1.2em;
  margin: 0.3em 0.8em 1em;
}

.thumbs figure {
  margin: 0;
  /* captions and breach lines carry plugin-shaped tokens (munged
   * mount paths, interface names) with no break opportunity; without
   * this one long token widens the page past a phone's viewport */
  overflow-wrap: anywhere;
  /* min-content = the image's natural width (text wraps under it), so
   * a default render shows 1:1 — librrd's chrome width varies per
   * graph, so no fixed width can promise that. Pinned by
   * tests/assets.rs. */
  width: min-content;
  max-width: 100%;
}

.thumbs figcaption {
  font-weight: bold;
  /* title (and its anomaly/silent marks) left, download link right on
   * the thumbnail's own width — same split as figure.period figcaption */
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 1em;
}

/* height: auto keeps the width/height *attributes* (the reserved box
 * from the last render, web::sizes) as an aspect ratio rather than a
 * distortion once max-width scales a wide graph down.
 *
 * The cap is viewport-based, NOT `100%`: the figure is sized
 * min-content, and in intrinsic sizing a percentage max-width lets the
 * image shrink to nothing — the card collapses to the caption's
 * one-character wrap width. A vw length resolves during that
 * computation, so the image contributes its natural width (or the
 * viewport cap on small screens) and the card follows it.
 *
 * 497px is also a hard ceiling: it's the chrome width librrd renders
 * for every graph but the rare one whose legend needs more columns
 * (many-series plugins) — uncapped, that one card would sit far wider
 * than the rest of the grid instead of scaling down like a narrow
 * viewport does. */
.thumbs img {
  display: block;
  max-width: min(497px, calc(100vw - 3.2em));
  height: auto;
}

p.breach {
  margin: 0.2em 0 0;
  font-size: 0.92em;
}

/* The compare page. Cards in one column, not the thumbs grid: reading
 * two curves against each other means one directly under the other, on
 * a shared horizontal time axis. The card discipline is .thumbs figure's
 * (min-content, so a default render shows 1:1 and nothing is resampled)
 * without the wrapping. */
.compare-picker {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.5em;
  margin: 0.3em 0.8em 0.6em;
}

.compare-graphs {
  display: flex;
  flex-direction: column;
  gap: 0.8em;
  margin: 0.3em 0.8em 0.6em;
}

.compare-graph {
  margin: 0;
  overflow-wrap: anywhere;
  width: min-content;
  max-width: 100%;
}

.compare-graph figcaption {
  font-weight: bold;
  /* title left, the drop link right on the graph's own width — the
   * thumbnail caption's split, said the same way */
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 1em;
}

.compare-graph .remove {
  font-weight: normal;
  font-size: 0.92em;
}

/* See .thumbs img for why the cap is viewport-based and the height auto.
 * The svg is a placed card: its viewBox scales the left pad with the
 * image, so alignment survives a narrow viewport.
 *
 * Only the viewport caps that svg — no 497px ceiling. The box is the
 * padded width every placed card shares, so no card can dwarf the
 * column the way one unusually wide legend can; a fixed cap under that
 * shared width would instead downscale every card non-integrally, the
 * resampling .thumbs img's ceiling exists to avoid. */
.compare-graph img {
  display: block;
  max-width: min(497px, calc(100vw - 3.2em));
  height: auto;
}

.compare-graph svg {
  display: block;
  max-width: calc(100vw - 3.2em);
  height: auto;
}

.compare-note {
  margin: 0 0.8em 1em;
  font-size: 0.92em;
  color: var(--muted);
}

/* Two headings, one rule: the compare page's correlation table under
 * the stack, and each host block on the correlations page. Both take
 * the same inline margin every card, paragraph and table around them
 * has, so each page keeps one left edge. The tables themselves are the
 * dense-table discipline shared with the anomalies table, minus the
 * nowrap — a field pair's two labels are the widest cells on the page
 * and wrapping them beats scrolling for them. */
.compare-corr h2,
.corr-host h2 {
  margin: 0.8em 0.8em 0.2em;
}

/* The `≡ +N` mark on a pair side that stands for a group: an aside to
 * the label it follows, never a second column, and never broken across
 * lines away from it. */
.corr-cluster {
  margin-left: 0.3em;
  opacity: 0.75;
  cursor: help;
  white-space: nowrap;
}

/* The groups themselves, listed above the table on the scoped page.
 * The heading is a table-of-contents label, not a section title, so it
 * takes the surrounding text size rather than an h3's default. */
.corr-clusters h3 {
  font-size: 1em;
  margin: 0.6em 0 0.2em;
}

.corr-clusters ul {
  margin: 0 0 0.6em;
  padding-left: 1.2em;
}

/* The fleet page's cut, said beside the table it shortened. */
p.corr-more {
  margin: 0.3em 0.8em;
  color: var(--muted);
}

/* None of the correlations page's absences is good news the way an
 * empty problems table is: a correlation is not a fault, so one not
 * found is not an all-clear, and discovery switched off — or gated off
 * per host by `analysis no`, or a host nothing polls — is a standing
 * configuration rather than an alarm. All of them read as the muted
 * statements of fact they are instead of taking `p.clean`'s green. */
p.corr-disabled,
p.corr-analysis-off,
p.corr-nopoll,
p.corr-nohosts,
p.corr-noscan,
p.corr-none {
  color: var(--muted);
}

/* Service page: the four period graphs. One column by default; two on a
 * screen wide enough that each still gets ~590px, which is where a
 * default-sized plot (400px plus librrd's chrome) stops being scaled
 * down enough to soften its legend. Wrapping flex could not do this:
 * it breaks a line when the items' natural widths overflow, so two
 * 697px panes wrapped to one column on a 1400px screen rather than
 * sharing it — four full-width graphs stacked, with no way to compare
 * one period against the next without scrolling past both. A grid
 * assigns the tracks first and lets `img`'s max-width fit the plot to
 * them. */
.periods {
  display: grid;
  gap: 0.4em 1.2em;
  margin: 0.3em 0.8em 1em;
}

@media (min-width: 88em) {
  .periods {
    /* `max-content`, not `1fr`: a plugin's plot may be any width, and
     * equal fractions stretched each track to half the viewport — a
     * 400px graph then sat at the left of a 985px track with the
     * caption's download link flung to the far edge. The track takes
     * the pane's own width instead, and `minmax(0, …)` still lets a
     * pair of wide panes shrink to share the row. */
    grid-template-columns: repeat(2, minmax(0, max-content));
  }
}

figure.period {
  margin: 0;
  /* a pane still scales down to fit its track (`img`'s max-width),
   * which the automatic minimum size would otherwise veto */
  min-width: 0;
  max-width: 100%;
}

figure.period figcaption {
  font-weight: bold;
  margin-bottom: 0.15em;
  /* period name left, download link right on the pane's own width */
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 1em;
}

/* The download links as one flex item, so a caption stays a two-part
 * split (title left, downloads right) however many formats are offered
 * — three items would spread title, PNG and SVG evenly across the
 * caption's width instead. */
.dls {
  display: flex;
  gap: 0.6em;
  white-space: nowrap;
}

/* Download link (host thumbnails, service panes, zoom toolbar):
 * secondary to whatever it sits beside, so it never competes with the
 * caption or the toolbar's navigation. */
.dl {
  font-weight: normal;
  font-size: 0.85em;
  color: var(--muted);
  white-space: nowrap;
}

figure.period img {
  display: block;
  max-width: 100%;
  height: auto;
}

/* Service page field table. The swatch is the colour the graph drew
 * that field with — an SVG fill attribute, because the CSP allows no
 * inline styles — run through the same dark-mode filter as the graph
 * itself, so it matches the curve on screen in both themes. */
.swatch {
  display: inline-block;
  width: 0.75em;
  height: 0.75em;
  margin-right: 0.4em;
  vertical-align: -0.05em;
  filter: var(--graph-filter);
}

/* Per-pane expected-range toggle: small, so four of them under the
   thumbnails read as controls rather than as content. */
/* A toggle reads as a control whether it is available or not: a
   disabled one styled as plain grey text is indistinguishable from a
   caption, and on a stacked graph — where both are disabled — the pair
   rendered as an orphan label "expected range trend" under the image. */
/* Body text colour, not link blue: the pill border already says
   "control", and two blue pills under every thumbnail read as prose
   links into somewhere else. */
.band-toggle {
  color: var(--fg);
  font-size: 0.85em;
  display: inline-block;
  padding: 0.1em 0.55em;
  border: 1px solid var(--control-line);
  border-radius: 999px;
  text-decoration: none;
}

.band-toggle.active {
  background: var(--group-bg);
  font-weight: 600;
}

.band-toggle.disabled {
  color: var(--muted);
  cursor: not-allowed;
  border-style: dashed;
}

.notgraphed {
  color: var(--muted);
  font-style: italic;
  font-weight: normal;
}

/* Zoom page: embedded graph, selection overlay, toolbar and readout. */
figure.zoom {
  position: relative;
  margin: 1em 0.8em;
  overflow-x: auto;
}

/* Never size this image. zoom.js reads rrd_graph_v's plot geometry in
   *image* pixels and getBoundingClientRect() in *CSS* pixels; they agree
   only while the image is unscaled, so making it responsive the way
   `.thumbs img` and `figure.period img` are would silently skew every
   click→time mapping. `figure.zoom` scrolls instead. Pinned by
   tests/assets.rs. */
figure.zoom img {
  display: block;
}

/* The zoom image is interactive only when the render supplied plot
 * geometry (zoom.js checks the same attribute); the crosshair is the
 * one visual cue that this graph, unlike every other, takes clicks. */
figure.zoom[data-plot-width] img {
  cursor: crosshair;
}

/* A render failure on the zoom page: the reason itself is inside the
   PNG, which is invisible to anything that does not paint pixels. */
.zoom-failed {
  color: var(--critical);
  font-weight: 600;
  margin: 0.6em 0;
}

.zoom-hint {
  color: var(--muted);
  font-size: 0.92em;
}

.zoom-marker,
.zoom-band {
  position: absolute;
  pointer-events: none;
}

.zoom-marker {
  width: 1px;
  background: var(--zoom-marker);
}

.zoom-band {
  background: var(--zoom-band);
}

.zoom-toolbar a,
.zoom-toolbar .zoom-off {
  margin-right: 0.75em;
}

/* A control with nowhere to go: the window is already flush against the
 * retention floor or the now+1h ceiling, so the server renders inert
 * text instead of a link that could only fail or move the wrong way. */
.zoom-toolbar .zoom-off {
  color: var(--muted);
}

/* The preset describing the current window: bold, not link-coloured —
 * "you are here", not "go here". */
.zoom-toolbar a.active {
  color: var(--fg);
  font-weight: bold;
  text-decoration: none;
}

.zoom-readout {
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}

/* The last-scan line: how fresh everything below it is. */
.scanline {
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}

/* Graph-colour provenance, dark mode only: the images are inverted in
 * CSS, so field colours are not the values docs/web.md pins. Shown by
 * token rather than by server-side condition — under `auto` the server
 * cannot know the reader's OS preference, and this way it needs no
 * JavaScript either. `display: none` also keeps it from screen readers
 * in light mode, where it would be a false statement. */
.dark-only {
  display: var(--dark-only);
  color: var(--muted);
  font-size: 0.9em;
}

/* base.html renders the note as `main`'s last child, and the host
 * page's `main` is a flex row: without a line of its own it lands
 * beside the sticky rail as a third column. Paired with that row's
 * `flex-wrap: wrap` — neither half works alone. */
main.host > .dark-only {
  flex: 0 0 100%;
}

/* Page footer: the server's reference clock and what this page cost to
 * build. Muted and out of the way — it is provenance, not content. */
.pagefoot {
  display: flex;
  gap: 1.2em;
  flex-wrap: wrap;
  margin: 1.5em 0 0;
  padding: 0.4em 0.8em;
  border-top: 1px solid var(--rule);
  color: var(--muted);
  font-size: 0.9em;
  font-variant-numeric: tabular-nums;
}

/* Narrow viewports: the sidebar becomes a block above the content
 * (column-reverse keeps it first on screen with the same DOM), loses
 * its stickiness and side border, and the thumbnails single-file. */
@media (max-width: 45em) {
/* The bar wraps to two-plus rows here; without JS this coarser
 * clearance keeps in-page anchors from landing under it (sidebar.js
 * publishes the measured height where it runs). */
:root {
  --bar-h: 5em;
}

main.host {
  flex-direction: column;
  /* the wide layout's flex-start serves the sticky sidebar; in column
   * direction it would shrink-wrap every row to content width and let
   * the 400px thumb cards overflow the page sideways */
  align-items: stretch;
}

/* The rail's contents join the column directly, so the two halves can
 * sit on either side of the content: the filter and the category
 * anchors are how you get *to* a graph and lead, while the scan facts
 * and the footer are provenance and follow. Stacked whole, they put
 * some 570px of chrome — including a wrapped failure message — between
 * a phone reader and the first graph. */
.host-side {
  display: contents;
}

.host-body {
  order: 0;
}

.svc-search {
  order: -2;
  margin: 0.4em 0.8em 0;
}

nav.anchors {
  order: -1;
  margin: 0.4em 0.8em;
}

.totop {
  order: 1;
  margin: 0.4em 0.8em;
}

.host-side .scaninfo {
  order: 2;
  margin: 0.4em 0.8em 0;
  padding-top: 0.6em;
}

.host-side .pagefoot {
  order: 3;
}

.svc-search input {
  width: min(28em, 100%);
}

/* the vertical rail reads poorly full-width: chips flow inline again */
nav.anchors {
  flex-direction: row;
  flex-wrap: wrap;
  gap: 0.3em 0.8em;
}

.host-side .pagefoot {
  flex-direction: row;
  gap: 1.2em;
}
}

/* The field page's two seasonal-profile figures (spec §3, "server-side
 * SVG"): presentation via classes only, read from the tokens above —
 * no inline `style=` anywhere in the generated markup (`web::figure`),
 * so the CSP's `style-src 'self'` never needs an exception for this
 * page. */
figure.profile {
  margin: 1em 0 1.5em;
}

figure.profile figcaption {
  color: var(--muted);
  font-size: 0.9em;
  margin-bottom: 0.3em;
}

/* The figures carry a fixed 720-unit viewBox and 10px axis type, so a
   container wider or narrower than that scales the TEXT with the
   drawing: ~4.6px labels at 360px, ~20px at 1440px, twice the body
   size. Capped at the intrinsic width so they never scale up, and the
   wrapper scrolls below it rather than shrinking the type away. */
/* The field page's own facts, above its figures. Two columns on a wide
   screen, stacked on a narrow one — a definition list is the shape the
   content already has. */
.fieldfacts {
  display: grid;
  grid-template-columns: max-content 1fr;
  gap: 0.25em 1em;
  margin: 0 0 1.5em;
  max-width: 70ch;
}

.fieldfacts dt {
  color: var(--muted);
  font-size: 0.9em;
}

.fieldfacts dd {
  margin: 0;
}

@media (max-width: 40em) {
  .fieldfacts {
    grid-template-columns: 1fr;
    gap: 0 0;
  }

  .fieldfacts dd {
    margin-bottom: 0.6em;
  }
}

.figure-scroll {
  overflow-x: auto;
}

/* Every container that can scroll sideways says so. Four background
   layers, no JavaScript and no extra markup: the two `local` gradients
   in the page colour ride WITH the content and sit exactly over the two
   `scroll`-attached shadows, so a shadow is visible only while there is
   content still to come that way — and a container whose content fits
   shows nothing at all. The colour is a token, so dark mode deepens it
   rather than inheriting a wash tuned for white. */
main,
.figure-scroll,
figure.zoom {
  background-color: var(--bg);
  background-image:
    linear-gradient(to right, var(--bg) 40%, transparent),
    linear-gradient(to left, var(--bg) 40%, transparent),
    radial-gradient(farthest-side at 0 50%, var(--scroll-shadow), transparent),
    radial-gradient(farthest-side at 100% 50%, var(--scroll-shadow), transparent);
  background-position: 0 0, 100% 0, 0 0, 100% 0;
  background-size: 2.5em 100%, 2.5em 100%, 0.8em 100%, 0.8em 100%;
  background-repeat: no-repeat;
  background-attachment: local, local, scroll, scroll;
}

.profile-figure,
.heatmap-figure {
  display: block;
  width: 720px;
  max-width: 100%;
  height: auto;
}

@media (max-width: 46em) {
  .profile-figure,
  .heatmap-figure {
    /* Below this the labels would be under ~7px; scroll at 1:1. */
    max-width: none;
  }
}

.axis-grid {
  stroke: var(--rule);
  stroke-width: 1;
}

.axis-label {
  fill: var(--muted);
  font-size: 10px;
  font-variant-numeric: tabular-nums;
}

.profile-mean {
  fill: none;
  stroke: var(--link);
  stroke-width: 1.5;
}

.profile-band {
  fill: var(--link);
  fill-opacity: 0.18;
  stroke: none;
}

/* Per-slot markers on the profile mean (`web::figure`): each carries
 * its pool size in a <title>; a pool under LOW_N samples is fainter
 * (`low-n`). Colour is the themed --link token, like the mean line, so
 * both themes are already covered. */
/* The last 24 hours over the band: what the field DID against what it
   usually does. Drawn heavier than the model's own mean line, because
   it is the observation and the mean is the reference. */
.profile-today {
  fill: none;
  stroke: var(--critical);
  stroke-width: 2;
  stroke-linejoin: round;
}

/* Where the observed line stops, so a day only half elapsed does not
   read as a field that fell off a cliff. */
.profile-now {
  stroke: var(--muted);
  stroke-width: 1;
  stroke-dasharray: 3 3;
}

.profile-pt {
  fill: var(--link);
  stroke: none;
}

.profile-pt.low-n {
  fill-opacity: 0.35;
}

/* The heatmap's quantized scale (`HEAT_BUCKETS = 8`, one class per
 * rank-based bucket) — the ramp itself is the `--heat-0`…`--heat-7` tokens above,
 * themed per `:root`/dark block like every other colour on the page. */
/* Cells are drawn edge to edge, so equal-bucket neighbours merge into
   one block and a `heat-none` gap dissolves into whichever bucket
   surrounds it. A hairline in the page colour restores the grid. */
rect[class^="heat-"] {
  stroke: var(--bg);
  stroke-width: 0.5;
}

.heat-0 {
  fill: var(--heat-0);
}
.heat-1 {
  fill: var(--heat-1);
}
.heat-2 {
  fill: var(--heat-2);
}
.heat-3 {
  fill: var(--heat-3);
}
.heat-4 {
  fill: var(--heat-4);
}
.heat-5 {
  fill: var(--heat-5);
}
.heat-6 {
  fill: var(--heat-6);
}
.heat-7 {
  fill: var(--heat-7);
}

/* "No data" must not read as "lowest bucket": the ramp's own floor sits
   close to the page ground by construction (that is what a sequential
   ramp is), so the distinction is carried by outline rather than by
   fill — a dashed border says absence in a way a colour cannot. */
.heat-none {
  fill: var(--bg);
  stroke: var(--control-line);
  stroke-width: 0.75;
  stroke-dasharray: 2 2;
}

.figure-refusal {
  color: var(--muted);
  font-style: italic;
}

/* The numbers behind a figure (`web::figure::Figure::table`): a
   supplement, not the reading, so it ships collapsed — the drawing
   above it is what the page is for. Open, it is the same dense grid as
   every other table here, one step smaller. */
details.figure-data {
  margin: 0.2em 0 0.6em;
}

details.figure-data > summary {
  width: max-content;
  cursor: pointer;
  color: var(--muted);
  font-size: 0.9em;
}

table.figure-numbers {
  margin: 0.4em 0;
  font-size: 0.92em;
  font-variant-numeric: tabular-nums;
}

/* Nothing in it is prose, so a wrapping cell would mean a column had
   silently grown too wide; the `.figure-scroll` around it scrolls. */
table.figure-numbers th,
table.figure-numbers td {
  white-space: nowrap;
}

/* Bottom-side, like the field table's: the header row stays first for
   a reader, and the caption is what carries the unit and what each
   column means. */
table.figure-numbers caption {
  caption-side: bottom;
  text-align: left;
  padding: 0.2em 0.6em;
  max-width: 80ch;
  white-space: normal;
  color: var(--muted);
  font-size: 0.95em;
}

/* Column sorting (static/sort.js): the control IS the header, so it
   carries none of a button's own chrome — a sortable column still has
   to read as a column heading. The arrow is drawn off `aria-sort`,
   the same attribute the script announces the sort with, so the mark a
   reader sees and the one a screen reader hears are one fact and
   cannot drift. Nothing here applies until the script has run: the
   server ships no `aria-sort` and no button. */
th .sortbtn {
  font: inherit;
  color: inherit;
  background: none;
  border: 0;
  padding: 0;
  text-align: inherit;
  cursor: pointer;
}

th .sortbtn:hover {
  text-decoration: underline;
}

th[aria-sort] .sortbtn::after {
  content: " ↕";
  color: var(--muted);
}

/* Which of a merged header's two buttons hosts the arrow. The direction
   still comes from `aria-sort` — one fact, one attribute — and this only
   picks the half it belongs to; a plain column's single button carries
   it whenever its column is the sorted one. */
th[aria-sort="ascending"] .sortbtn.on::after {
  content: " ↑";
  color: var(--fg);
}

th[aria-sort="descending"] .sortbtn.on::after {
  content: " ↓";
  color: var(--fg);
}

/* A merged column (`p5…p95`, `warn/crit`, `state·since`): two values in
   one cell, each its own sortable part, with a separator that belongs to
   neither. Two columns cost two paddings and two borders for numbers
   that are read as a pair anyway — merging them is what made room for
   the quartiles without widening the table. */
td.pair {
  white-space: nowrap;
}

.sep {
  color: var(--muted);
  padding: 0 0.2em;
}

/* Print: the page content is the graphs and tables; the chrome is
 * screen furniture. Three things that look like chrome are not, and stay:
 *
 * - the footer, because it carries the clock the page was built at.
 *   Every relative age on paper hangs off it, and an undated sheet of
 *   graphs is the one artefact a monitoring UI must never produce.
 * - the outage banner, because "unreachable since 3d ago — showing last
 *   known data" is the reading of the graphs beside it, not decoration.
 *   Printing it away leaves stale curves looking current.
 * - the host page's window word, because it is the sheet's only
 *   statement of which window every graph on it covers. Only the three
 *   windows the sheet is NOT showing go, since those are pure control. */
@media print {

  /* The dark theme's graph inversion is set by `:root[data-theme=dark]`,
     which is server-rendered from the cookie and so survives any print
     query. Left alone, every graph and every field-table swatch arrives
     on paper as a near-black slab — and `.dark-only`, the line that
     explains the colours are not the pinned palette, is hidden below. */
  :root {
    --graph-filter: none;
  }

  /* the bar is `display: none` below and out of flow anyway; its
     reserved gap would print as a blank strip on every sheet */
  body {
    padding-top: 0;
  }
.topbar,
.skip,
.dark-only,
.zoom-toolbar,
.dl,
.dls {
  display: none;
}

/* Only the windows this sheet is NOT showing: a link is not a control
 * on paper, but the current word is the sheet's own statement of which
 * window every graph below it covers, and an undated span is as bad as
 * an undated sheet. Whole-bar hiding would take that statement with it. */
.periodbar a:not(.active) {
  display: none;
}

/* The host page's only footer lives inside the sidebar (base.html
 * suppresses the bottom one there), so the rail cannot be hidden whole
 * or the page loses its clock — and a `display: none` ancestor cannot
 * be seen through. Hide the rail's own contents instead and let it
 * flow as the footer it now is. */
.host-side > :not(.pagefoot) {
  display: none;
}

.host-side {
  position: static;
  flex: none;
  max-height: none;
  overflow: visible;
  border-left: none;
  padding: 0;
}

/* The rail's footer is stacked and tight for a 12em column; on paper it
 * is the page footer, so it takes the row layout and spacing every
 * other page's footer has. */
.host-side .pagefoot {
  flex-direction: row;
  gap: 1.2em;
}

main,
main.host {
  overflow: visible;
  display: block;
}

/* The overview pages use the sticky top bar as their visible title on
 * screen. Printing hides that bar, so restore only their top-level
 * screen-reader heading as ordinary ink; the many `.vh` help twins stay
 * hidden. */
main > h1.vh,
.host-body > h1.vh {
  position: static;
  width: auto;
  height: auto;
  overflow: visible;
  clip-path: none;
  white-space: normal;
}

/* Controls with nowhere to go on paper. The page-links nav and a 404's
 * offered links are pure navigation; the noscript refresh notice
 * describes a reload that paper cannot do. */
.pagelinks,
.notfound-links,
.noscript-refresh {
  display: none;
}

/* The overlay toggles go the way the window links do: an inactive one
 * is pure control, but the ACTIVE one is the sheet's only statement
 * that the graph beside it carries a band or a trend line — and a
 * disabled one is a control too, reason and all. */
.band-toggle:not(.active) {
  display: none;
}

/* The sort arrow is a control's state; beside a heading on paper it
 * reads as a stray glyph. */
th .sortbtn::after {
  content: "";
}

/* Nothing sticks to a sheet of paper: a header pinned to the viewport
 * prints once, at whatever offset the pin left it, and every row it
 * was meant to head prints without it. The selectors repeat the screen
 * rules' own shape because they must match their specificity — the
 * sticky ones live in a `min-width` query that a wide print sheet
 * satisfies too. `thead` as a header GROUP is the paper answer: the
 * browser reprints the column headers at the top of each sheet. */
table.board thead th,
table.problems thead th,
table.anomalies thead th,
table.fields thead th,
table.board tbody tr.group th,
table.problems tbody tr.group th,
table.anomalies tbody tr.group th,
.host-body h2,
.topbar {
  position: static;
}

thead {
  display: table-header-group;
}

/* A row split across two sheets is a row read twice and understood
 * neither time; a figure split from its caption is worse. */
tr,
figure,
details {
  break-inside: avoid;
}

/* A link cannot be clicked on paper, but the ones on these pages are
 * also the field, host and service NAMES — printing them in a link
 * colour that a mono printer renders as pale grey loses the text
 * itself. Full contrast, underlined, so they still read as links. */
a {
  color: var(--fg);
  text-decoration: underline;
}

/* The scroll cue is an affordance for a scrollport; paper has none,
 * and the shadow would print as a smudge down each edge. */
main,
.figure-scroll,
figure.zoom {
  background-image: none;
}
}
