/* ProbeGlass dashboard styles.
 *
 * One stylesheet, no preprocessor. Everything themes through custom properties
 * so the dashboard can switch appearance (theme, accent, density, layout)
 * without re-rendering anything, and so the SVG charts — which use classes, not
 * colours — re-colour themselves for free.
 *
 * Layout is driven by data attributes on <html>:
 *   data-theme   auto | dark | light
 *   data-accent  azure | violet | emerald | amber | rose | slate
 *   data-density comfortable | compact
 *   data-layout  grid | list
 */

/* ------------------------------------------------------------------ tokens */

:root {
  color-scheme: dark;

  --bg: #0e1116;
  --surface: #151a21;
  --surface-2: #1c232c;
  --surface-3: #232c37;
  --border: #262e3a;
  --border-strong: #38424f;
  --text: #e6ebf2;
  --text-dim: #9aa7b8;
  --text-faint: #6b7889;
  --shadow: 0 10px 30px rgb(0 0 0 / 35%);

  --ok: #22c55e;
  --warn: #f59e0b;
  --bad: #fb923c;
  --crit: #ef4444;
  --unknown: #64748b;
  --paused: #7d8899;
  /* Maintenance: a box that is away because somebody said it would be. Violet,
     and the only colour in the ramp that is not a judgement about the probe —
     green, amber and red all answer "is it working", and this one answers "did
     you arrange it". It has to be legible against a card that may be any of the
     three, which is why it is a ring and a pill rather than a repainted status. */
  --maint: #a78bfa;
  /* A dependency: the box is away because something in front of it is. Sky blue
     rather than violet, because the two say different things — "I arranged this" and
     "this happened to me" — and a dashboard where they looked the same would lose
     the distinction this feature exists to draw. */
  --dep: #38bdf8;

  --accent: #60a5fa;
  --accent-ink: #0b1220;

  /* The map's own two colours. They are not the surface ramp: the canvas behind
     them is already --surface-2, and land that is one step of that same ramp is a
     shape that vanishes in whichever theme happens to be the wrong way round.
     Land is painted over the graticule and under the tiles, so it stays readable
     whether or not anybody configured a tile server. */
  --map-land: #212c39;
  --map-coast: #46586e;
  /* A border is a line, not an edge of a shape, and a label has to be readable
     over water, over land and over somebody's tiles. Both are one step brighter
     than the coast and dimmer than the text, so that the pins stay the loudest
     thing on the map — they are the only part of it that means anything. */
  --map-border: #55697f;
  --map-label: #c7d4e4;
  /* A country border is the brighter of the three border colours: it is where a
     country ends, and the province line that meets it is where a district does.
     The district line is the dimmest, because it is the finest thing this map
     draws and the one that would turn a country into a texture if it were as
     loud as the two above it — it is only on screen from zoom 8. Water is one
     colour for the lakes and the rivers both — it is the same water, and a lake
     whose colour did not match the river running into it would be a lake
     somebody had drawn by hand.

     The district fill and the district name are the third and fourth of that
     family, and they are the only two colours on the map that are deliberately
     too quiet to read on their own. A district is the one name here that is
     drawn on the area it names rather than on a place, and the point of it is
     that the area still shows through: the fill is a tint of a few percent and
     the name sits at a little over half opacity, so an Austrian view reads as
     districts with their names on them rather than as a second set of labels
     competing with the cities. Both are the same hue as the border between
     them, which is what makes the fill and the line belong to each other. */
  --map-country: #8399b0;
  --map-district: #3d4f63;
  --map-district-fill: #7a96ba17;
  --map-district-name: #94a8c0;
  --map-water: #2b4257;

  /* ------------------------------------------------------------- measurements
   *
   * Two scales, and nothing outside them.
   *
   * "The spacing is uneven" is what somebody says when every rule has been given
   * the number that looked right in that one place — this file had fifteen
   * distinct font sizes and eighteen distinct paddings, so two things that are
   * the same kind of thing were never quite the same size. A scale is what stops
   * that: there are six steps, a value is one of them, and a new rule picks the
   * step that matches what it is rather than the one that looks good on its own.
   *
   * The steps are 4px apart up to 16 and coarser above, which is what a 4px grid
   * costs: 4, 8, 12, 16, 24, 32 — and one step below it, 2px, for the places where
   * two things have to be a hair apart rather than a gap apart: the vertical
   * padding of a tag, the space between the rows of a small list. Two pixels is
   * not a gap and does not pretend to be one; it is on the scale because a rule
   * that invented its own 3px would be the beginning of the same mess.
   *
   * The type scale is five steps for the same reason. --text-1 is a heading,
   * --text-2 the name of a thing, --text-3 running text, --text-4 the second
   * sentence about it, --text-5 the label above a field. */
  --sp-0: 2px;
  --sp-1: 4px;
  --sp-2: 8px;
  --sp-3: 12px;
  --sp-4: 16px;
  --sp-5: 24px;
  --sp-6: 32px;

  --text-0: 2rem;      /* a display figure: the clock face in the header */
  --text-1: 1.3rem;    /* a page or a dialog title */
  --text-2: 1rem;      /* the name of a thing: a card, a row, a heading */
  --text-3: 0.9rem;    /* running text, controls, table cells */
  --text-4: 0.82rem;   /* the second sentence: hints, counts, timestamps */
  --text-5: 0.75rem;   /* the label above a field, a tag, a badge */

  --radius: 12px;
  --radius-sm: 8px;
  --radius-xs: 6px;

  /* One height for everything that is pressed or typed into, so that a button
     beside an input inside a row is the same box as the field next to it. */
  --ctl-h: 32px;
  --ctl-h-sm: 26px;

  /* The three older names, kept because most of this file still says them and
     they mean exactly these steps. */
  --pad: var(--sp-4);
  --pad-sm: var(--sp-3);
  --gap: var(--sp-4);
  --font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
    "Helvetica Neue", Arial, sans-serif;
  --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;

  /* The colours a group may be given. They are the six accents, written out here
     rather than borrowed from --accent: the accent is the one this installation is
     wearing, and a group's colour has to be the one somebody chose for it — so all
     six exist at once, in both themes, whatever the setting above happens to be.
     A group's colour is a name and not a hex value for this reason and no other:
     each of these has a second value for the light theme below, and a stored
     #60a5fa would be the dark theme's azure on a white page. */
  --grp-azure: #60a5fa;
  --grp-violet: #a78bfa;
  --grp-emerald: #34d399;
  --grp-amber: #fbbf24;
  --grp-rose: #fb7185;
  --grp-slate: #94a3b8;
}

[data-accent='azure'] { --accent: #60a5fa; }
[data-accent='violet'] { --accent: #a78bfa; }
[data-accent='emerald'] { --accent: #34d399; }
[data-accent='amber'] { --accent: #fbbf24; --accent-ink: #1a1206; }
[data-accent='rose'] { --accent: #fb7185; --accent-ink: #23060c; }
[data-accent='slate'] { --accent: #94a3b8; }

/* "auto" follows the operating system; "light" forces it regardless. The two
   blocks hold the same palette because one is a media query and the other is an
   attribute selector. */
@media (prefers-color-scheme: light) {
  :root[data-theme='auto'] {
    color-scheme: light;
    --bg: #f5f7fb;
    --surface: #ffffff;
    --surface-2: #eef2f7;
    --surface-3: #e6ebf3;
    --border: #dce3ec;
    --border-strong: #c2cddb;
    --text: #131922;
    --text-dim: #5a6a7e;
    --text-faint: #8296ab;
    --shadow: 0 10px 28px rgb(15 23 42 / 10%);

    --ok: #16a34a;
    --warn: #d97706;
    --bad: #ea580c;
    --crit: #dc2626;
    --unknown: #94a3b8;
    --paused: #94a3b8;
    --maint: #7c3aed;
    --dep: #0369a1;

    --accent: #2563eb;
    --accent-ink: #ffffff;
    --grp-azure: #2563eb;
    --grp-violet: #7c3aed;
    --grp-emerald: #059669;
    --grp-amber: #b45309;
    --grp-rose: #e11d48;
    --grp-slate: #475569;
    --map-land: #e7eef7;
    --map-coast: #afc0d4;
    --map-border: #8fa4bd;
    --map-label: #33415a;
    --map-country: #657c9b;
    --map-district: #a3b6cb;
    --map-district-fill: #46608012;
    --map-district-name: #6b7a8e;
    --map-water: #b9cfe4;
  }

  :root[data-theme='auto'][data-accent='azure'] { --accent: #2563eb; }
  :root[data-theme='auto'][data-accent='violet'] { --accent: #7c3aed; }
  :root[data-theme='auto'][data-accent='emerald'] { --accent: #059669; }
  :root[data-theme='auto'][data-accent='amber'] { --accent: #b45309; --accent-ink: #ffffff; }
  :root[data-theme='auto'][data-accent='rose'] { --accent: #e11d48; --accent-ink: #ffffff; }
  :root[data-theme='auto'][data-accent='slate'] { --accent: #475569; --accent-ink: #ffffff; }
}

:root[data-theme='light'] {
  color-scheme: light;
  --bg: #f5f7fb;
  --surface: #ffffff;
  --surface-2: #eef2f7;
  --surface-3: #e6ebf3;
  --border: #dce3ec;
  --border-strong: #c2cddb;
  --text: #131922;
  --text-dim: #5a6a7e;
  --text-faint: #8296ab;
  --shadow: 0 10px 28px rgb(15 23 42 / 10%);

  --ok: #16a34a;
  --warn: #d97706;
  --bad: #ea580c;
  --crit: #dc2626;
  --unknown: #94a3b8;
  --paused: #94a3b8;

  --accent: #2563eb;
  --accent-ink: #ffffff;
  --grp-azure: #2563eb;
  --grp-violet: #7c3aed;
  --grp-emerald: #059669;
  --grp-amber: #b45309;
  --grp-rose: #e11d48;
  --grp-slate: #475569;
  --map-land: #e7eef7;
  --map-coast: #afc0d4;
  --map-border: #8fa4bd;
  --map-label: #33415a;
  --map-country: #657c9b;
  --map-district: #a3b6cb;
  --map-district-fill: #46608012;
  --map-district-name: #6b7a8e;
  --map-water: #b9cfe4;
}

:root[data-theme='light'][data-accent='azure'] { --accent: #2563eb; }
:root[data-theme='light'][data-accent='violet'] { --accent: #7c3aed; }
:root[data-theme='light'][data-accent='emerald'] { --accent: #059669; }
:root[data-theme='light'][data-accent='amber'] { --accent: #b45309; --accent-ink: #ffffff; }
:root[data-theme='light'][data-accent='rose'] { --accent: #e11d48; --accent-ink: #ffffff; }
:root[data-theme='light'][data-accent='slate'] { --accent: #475569; --accent-ink: #ffffff; }

:root[data-density='compact'] {
  /* Compact is the same scale one step tighter rather than a second set of
     numbers: the steps are the ones above, moved down where there is one to move
     to. Nothing here is a value the scale does not have. */
  --pad: var(--sp-3);
  --pad-sm: var(--sp-2);
  --gap: var(--sp-3);
  --radius: var(--radius-sm);
}

/* ------------------------------------------------------------------- reset */

*, *::before, *::after { box-sizing: border-box; }

/* The `hidden` attribute is how the dashboard switches between the boot
 * spinner, the auth card and the shell. It has to win over any display the
 * element's own class asks for, and by default it does not: `[hidden]` is a
 * user-agent rule, so a `.boot { display: grid }` in this file overrides it.
 * Without this line, `$('#boot').hidden = true` sets an attribute and changes
 * nothing — the spinner stays on screen, full height, over a dashboard that is
 * loading perfectly behind it. That failure looks exactly like a hang and is
 * invisible to any check that does not render CSS.
 *
 * !important is the point of the rule rather than a shortcut: the whole job is
 * to outrank the more specific selectors below, whatever they say. */
[hidden] { display: none !important; }

body {
  margin: 0;
  background: var(--bg);
  color: var(--text);
  font: 15px/1.5 var(--font);
  -webkit-font-smoothing: antialiased;
}

h1, h2, h3 { margin: 0; font-weight: 600; line-height: 1.25; }
h1 { font-size: var(--text-1); }
h2 { font-size: var(--text-2); }
h3 { font-size: var(--text-2); }
p { margin: 0; }
a { color: var(--accent); }

.muted { color: var(--text-dim); }
.small { font-size: var(--text-3); }
.num { text-align: right; font-variant-numeric: tabular-nums; }

:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: 4px;
}

@media (prefers-reduced-motion: reduce) {
  * { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; }
}

/* -------------------------------------------------------------- boot/login */

.boot {
  display: grid;
  place-content: center;
  justify-items: center;
  gap: var(--sp-4);
  min-height: 100vh;
  color: var(--text-dim);
}

/* Failure panel shown on the boot screen by app.js or watchdog.js. */
.boot-error {
  display: grid;
  justify-items: start;
  gap: var(--sp-3);
  max-width: 46rem;
  padding: var(--sp-5);
  border: 1px solid var(--border);
  border-radius: 12px;
  background: var(--surface);
  color: var(--text);
  text-align: left;
}

.boot-error h2 {
  margin: 0;
  font-size: var(--text-1);
}

.boot-error p {
  margin: 0;
}

/* The raw error, only worth showing when there is one. */
.boot-detail {
  max-width: 100%;
  max-height: 12rem;
  margin: 0;
  overflow: auto;
  padding: var(--sp-3) var(--sp-3);
  border-radius: 8px;
  background: var(--bg);
  color: var(--text-dim);
  font-size: var(--text-4);
  white-space: pre-wrap;
  word-break: break-word;
}

.spinner {
  width: 28px;
  height: 28px;
  border: 3px solid var(--border);
  border-top-color: var(--accent);
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
}

@keyframes spin { to { transform: rotate(360deg); } }

.auth-view {
  display: grid;
  place-items: center;
  min-height: 100vh;
  padding: var(--sp-5);
}

.auth-card {
  width: min(400px, 100%);
  display: grid;
  gap: var(--sp-4);
  padding: var(--sp-6);
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
}

.brand {
  display: flex;
  align-items: center;
  gap: var(--sp-3);
  font-weight: 650;
  letter-spacing: -0.01em;
}

.brand-lg { font-size: var(--text-1); }

/* ------------------------------------------------------------------ topbar */

/* Three columns: brand and tabs, the search field, the buttons. The two outer
   columns take equal shares of the leftover width, which is the only reason the
   search field in the middle is centred on the window rather than on whatever the
   buttons happen to measure.
   The middle column is a fixed 240px rather than `auto`, and that is not
   decoration: an `auto` track is only ever as wide as its min-content once the two
   `1fr` tracks have taken the free space, so the field would sit at 189px in a wide
   window and at 189px in a narrow one — the same width, but not the one intended,
   and it would start shrinking the sides by surprise. Underneath the breakpoints
   below, the bar falls back to wrapping with the search field on a line of its own.
   The side columns cannot shrink past their content, so if the three together want
   more room than there is, the sides end up underneath the field and the buttons
   stop taking clicks. That is what the two width breakpoints below are for, and
   the numbers in them are measured, not guessed. */
.topbar {
  position: sticky;
  top: 0;
  z-index: 20;
  display: grid;
  grid-template-columns: 1fr 240px 1fr;
  align-items: center;
  gap: var(--sp-4);
  padding: var(--sp-3) var(--sp-5);
  background: color-mix(in srgb, var(--bg) 88%, transparent);
  backdrop-filter: blur(10px);
  border-bottom: 1px solid var(--border);
}

.topbar-side { display: flex; align-items: center; gap: var(--sp-4); min-width: 0; }

.tabs { display: flex; gap: var(--sp-1); }

.tab {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  padding: var(--sp-2) var(--sp-3);
  border: 0;
  border-radius: 999px;
  background: transparent;
  color: var(--text-dim);
  font: inherit;
  font-weight: 500;
  cursor: pointer;
  white-space: nowrap;
}

.tab svg { width: 16px; height: 16px; flex: none; }

.tab:hover { background: var(--surface-2); color: var(--text); }
.tab.active { background: var(--surface-3); color: var(--text); }

/* `flex-wrap` is a guard rather than a design: the side columns cannot shrink past
   their content, so if the three columns together ever want more room than the bar
   has, the buttons would slide underneath the search field and stop taking clicks.
   Wrapping them inside their own column costs a taller bar instead, which is a
   visible nuisance rather than a silent one. The two breakpoints below are meant to
   keep it from ever coming to that. */
.topbar-actions { justify-content: flex-end; gap: var(--sp-3); flex-wrap: wrap; }

/* The row of working controls, and the two things that can stand in for it.
   At every width above 1133 the row is the row: `.topbar-extra` is a plain flex
   container inside `.topbar-actions`, `#topbar-more` is nowhere, and nothing below
   this line has any effect on the bar. The media queries further down are what
   make them a panel and a button. */
/* The gap is the one `.topbar-actions` used to put between these same controls.
   Nesting them in a container is what makes the row and the panel two layouts of
   one set, and a wider gap here would be a wider row than the bar had before —
   which at 1600px is the difference between one line and two. */
.topbar-extra { display: flex; align-items: center; gap: var(--sp-3); min-width: 0; }

/* The two icon buttons the bar keeps in its row: the ⋮ and, below the width where
   the search field leaves the bar, the magnifier that brings the field back. The
   class is its own rather than `.icon-btn`, which is the small square button the
   dialogs and the widget tiles already use, and which this is not: these are 34px
   and carry a border, because they are controls that have to look like ones at the
   end of a row of controls.
   Each is shown only by the query that gives it its place, so a button and the
   thing it stands in for can never disagree about which of them is on screen. */
.bar-icon-btn {
  display: none;
  align-items: center;
  justify-content: center;
  width: 34px;
  height: 34px;
  padding: 0;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface);
  color: var(--text-dim);
  cursor: pointer;
}

.bar-icon-btn:hover { border-color: var(--border-strong); background: var(--surface-2); color: var(--text); }
.bar-icon-btn[aria-expanded='true'] { border-color: var(--accent); color: var(--text); }
.bar-icon-btn svg { width: 16px; height: 16px; }

/* The live indicator. It carries the same three states in the dot and in the
   word, so it reads the same to somebody who cannot tell the colours apart: the
   word is what changes state, the colour only agrees with it. Off is drawn like
   a control that is off — flat, quiet — rather than like a fault, because turning
   the stream off is something somebody asked for. */
.live-chip {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-2);
  padding: var(--sp-2) var(--sp-3);
  border: 1px solid var(--border);
  border-radius: 999px;
  background: transparent;
  color: var(--text-dim);
  font: inherit;
  font-size: var(--text-4);
  font-weight: 500;
  cursor: pointer;
}

.live-chip:hover { background: var(--surface-2); color: var(--text); }

.live-dot {
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: currentColor;
  flex: none;
}

.live-chip[data-live="on"] { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 38%, transparent); }
.live-chip[data-live="waiting"] { color: var(--paused); border-color: color-mix(in srgb, var(--paused) 38%, transparent); }
.live-chip[data-live="off"] { color: var(--text-faint); }

/* A pulse while the stream is up, and only then: the point of the animation is to
   say "this is working right now". It lives here rather than in the reduced-motion
   block below so that the global rule that zeroes the durations reaches it too. */
.live-chip[data-live="on"] .live-dot { animation: live-pulse 2.4s ease-in-out infinite; }

@keyframes live-pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.35; }
}

/* Two collapses, and the bar gives up words in the order it can spare them.
   Both are measured in a browser rather than guessed, with the example sensors on
   the dashboard and the twelve-character user name the tests sign in with — the
   longest thing in the bar that is not a fixed size, and so the one it has to hold.
   What the two sides ask for: on the left 594 with the tab names and the brand,
   222 without them. On the right, on the dashboard, 875 with the labels of the two
   segmented controls, 626 without those labels (they are 249, most of them the five
   of the layout switcher), 409 with no words at all, and 237 with no words while
   the header is being arranged — that last is narrower because the switcher stands
   down and the tools pill takes on the button that replaces it, which is where the
   width to rearrange a header at all comes from. The settings page is narrower
   everywhere (360 and 143) since it has neither control. The field in the middle is
   a fixed 240px.
   A grid column is half of what is left over, so the bar holds one line at twice
   the wider side plus the 240px field, the two 18px gaps and the 40px of padding:
   2066 with every word, 1568 with the segmented labels gone, 1134 with all of them
   gone.
   So the words go in two steps. First the segmented labels, which say the same
   thing as the icon beside them and are the cheapest to lose: below 2066. That step
   is not cosmetic — at 1800 the dashboard asked for 2066, and a right column that
   cannot have the width it wants wraps inside itself, which is a second row of
   buttons in a bar that is supposed to have one. Then every other word: the tab
   names, the brand, and the four that belong to a button — New sensor, the live
   indicator, the account — below 1568. Each of those carries its name as a title and
   as an aria-label, because a `display: none` word is not in the accessibility tree
   and an icon alone has no name. */
@media (max-width: 2065px) {
  .seg-btn span { display: none; }
}

@media (max-width: 1567px) {
  /* The segmented labels are already gone from the rule above; these are the ones it
     does not reach. The brand word goes with them: the mark beside it says the same
     thing in one glyph, and the bar keeps that. */
  .brand-name,
  #tabs .tab > span,
  #new-sensor > span,
  .live-word,
  .user-chip-name { display: none; }
}

@media (max-width: 1133px) {
  /* One line still, but the four working controls are no longer on it: they fold
     into the ⋮ beside them, and the search field takes the room they gave back.
     Wrapping is what this replaces. A wrapping bar does not stay a bar: below
     1133 the two sides each took a line of their own, and from 600px down each
     also broke inside itself — three rows at 600, four at 440. `nowrap` here is
     the whole point: the row cannot come apart, because there is nothing left in
     it that is wider than the space it has. */
  .topbar { display: flex; flex-wrap: nowrap; gap: var(--sp-3); }
  .topbar-side { flex: 0 1 auto; flex-wrap: nowrap; gap: var(--sp-3); min-width: 0; }
  .topbar-actions { flex: 0 0 auto; }
  .brand { flex: none; }
  .tabs { overflow-x: auto; }
  .search { flex: 1 1 120px; min-width: 0; }
  /* One class more specific than the rule hiding it, and inside the query that
     folds the row away: the two cannot come apart, because there is no width at
     which one applies and the other does not. */
  .topbar-actions > #topbar-more { display: inline-flex; }
  .topbar-extra { display: none; }

  /* The same controls, as a panel. They are not rebuilt for this and there is
     only ever one copy of each: the row and the panel are two layouts of one set
     of elements, which is why a switcher repainted by the router, a tools pill
     emptied and refilled by widgets.js and a live chip rewritten on every event
     all keep working here without knowing this exists.
     The panel hangs off the bar, so it is `absolute` — the bar is `sticky` and is
     therefore already a containing block, and the panel stays with it as the page
     scrolls underneath. */
  .topbar.menu-open .topbar-extra {
    display: flex;
    flex-direction: column;
    align-items: stretch;
    gap: var(--sp-0);
    position: absolute;
    z-index: 30;
    top: calc(100% - 4px);
    right: var(--sp-3);
    min-width: 236px;
    max-width: calc(100vw - 20px);
    /* It is anchored under the bar and cannot go above it, so on a short window —
       a phone held sideways — it would otherwise run off the bottom and take its
       last rows with it. */
    max-height: calc(100vh - 76px);
    overflow-y: auto;
    padding: var(--sp-2);
    border: 1px solid var(--border-strong);
    border-radius: var(--radius);
    background: var(--surface-2);
    box-shadow: var(--shadow);
  }

  /* A segmented control is a row of buttons in a pill; in a panel it is a list of
     rows, so it loses the pill and stacks. The words come back with it — they are
     `display: none` above 2065 and 1567 to buy the bar a line, and a panel has no
     line to buy. An icon with no word in a menu is a menu nobody can read. */
  .topbar.menu-open .topbar-extra .segmented {
    flex-direction: column;
    align-items: stretch;
    gap: 0;
    padding: 0;
    border: 0;
    background: transparent;
  }
  .topbar.menu-open .topbar-extra .seg-btn {
    width: 100%;
    justify-content: flex-start;
    gap: var(--sp-3);
    padding: var(--sp-2) var(--sp-3);
    border-radius: var(--radius-sm);
    font-size: var(--text-3);
  }
  .topbar.menu-open .topbar-extra .seg-btn[aria-pressed='true'] {
    background: var(--surface-3);
    box-shadow: none;
  }
  .topbar.menu-open .topbar-extra .seg-btn span,
  .topbar.menu-open .topbar-extra #new-sensor > span,
  .topbar.menu-open .topbar-extra .live-word { display: inline; }
  .topbar.menu-open .topbar-extra .seg-btn svg { width: 16px; height: 16px; }

  /* The two buttons that were in the row become rows as well, and the accent
     goes: a menu with one loud row in it reads as a broken menu, and the row
     says what it does in words here, which is what the colour was standing in
     for when it was a plus and a title. */
  .topbar.menu-open .topbar-extra #new-sensor,
  .topbar.menu-open .topbar-extra .live-chip {
    width: 100%;
    justify-content: flex-start;
    gap: var(--sp-3);
    margin-top: var(--sp-2);
    padding: var(--sp-2) var(--sp-3);
    border: 0;
    border-radius: var(--radius-sm);
    background: transparent;
    color: var(--text);
    font-size: var(--text-3);
  }
  .topbar.menu-open .topbar-extra #new-sensor:hover,
  .topbar.menu-open .topbar-extra .live-chip:hover { background: var(--surface-3); }
  .topbar.menu-open .topbar-extra #new-sensor svg { width: 16px; height: 16px; }
  .topbar.menu-open .topbar-extra .live-chip[data-live='on'] { color: var(--ok); }
  .topbar.menu-open .topbar-extra .live-chip[data-live='waiting'] { color: var(--paused); }
  .topbar.menu-open .topbar-extra .live-chip[data-live='off'] { color: var(--text-faint); }
}

/* The search field leaves the bar before the four controls do.
   A field that fits on the remaining room at 500px is a stub seven characters
   wide, and a stub is worse than no field at all: it is in the way of the tabs and
   it cannot be searched in. Below this width the field is not in the row — and
   nothing is put in the ⋮ in its place, because the field is not a thing that gets
   folded away: a magnifier takes its place in the row, and pressing it brings the
   field back over the bar at the full width of it, which is the only shape a search
   field is worth having on a phone. `inset: 0` is the padding box of the bar, so the
   field lands exactly where the bar's contents were and the bar's own padding stays
   around it. */
@media (max-width: 619px) {
  .topbar { padding: var(--sp-3) var(--sp-3); }
  .search { display: none; }
  .topbar.search-open .topbar-side,
  .topbar.search-open .topbar-actions { display: none; }
  .topbar.search-open .search {
    display: flex;
    align-items: center;
    position: absolute;
    inset: 0;
    background: var(--bg);
  }
  .topbar.search-open .search input { width: 100%; }
  .topbar-actions > #topbar-search { display: inline-flex; }
}

/* The tab is an icon-only control at every width below 1567, and 26px of side
   padding on a 42px button is most of the button — padding that was sized to sit
   around a word. Below 380 it goes back to hugging its icon, which is 32 pixels of
   the row bought back for the two things beside it that are still words if you open
   them: at 375 the row is the mark and the four tabs on the left — 24 and 10 and
   148 — the two icon buttons with the account on the right, 126, and the ten and
   the 24 either side of them, which is 342 of 375. Above 380 there is room for the
   wider tab — 374 of 380 is the arithmetic — so the room goes to the tab. */
@media (max-width: 379px) {
  .tab { padding: var(--sp-2) var(--sp-2); }
}

/* And in the last stretch the mark goes with it. The four tabs and the two icon
   buttons and the account are 320 of the 320 of the narrowest phone still in use
   once the tabs have hugged their icons; the mark and the ten pixels beside it are
   the only thing left that says nothing the first tab does not already say, and the
   first tab is the way home. Nothing is lost with it: it is `alt=""` and it is not a
   control. */
@media (max-width: 359px) {
  .brand { display: none; }
}

.search { position: relative; }

.search input {
  width: 100%;
  padding: var(--sp-2) var(--sp-3);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface);
  color: var(--text);
  font: inherit;
}

.search input:focus { outline: 2px solid var(--accent); outline-offset: 1px; }

/* The result list hangs under the field rather than beside it: it belongs to the
   field, and a search box that pushes the page down every time somebody types is
   a search box people stop using. Absolutely placed, so the top bar never grows. */
.search-results {
  position: absolute;
  top: calc(100% + 6px);
  left: 50%;
  transform: translateX(-50%);
  width: min(420px, calc(100vw - 32px));
  max-height: min(60vh, 520px);
  overflow-y: auto;
  z-index: 30;
  padding: var(--sp-2);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface);
  box-shadow: 0 18px 40px color-mix(in srgb, #000 25%, transparent);
}

.search-group + .search-group { margin-top: var(--sp-1); padding-top: var(--sp-1); border-top: 1px solid var(--border); }

.search-group-head {
  margin: var(--sp-1) var(--sp-2) var(--sp-0);
  font-size: var(--text-5);
  font-weight: 650;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.search-hit {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-3);
  width: 100%;
  padding: var(--sp-2) var(--sp-2);
  border: 0;
  border-radius: var(--radius-sm);
  background: transparent;
  color: var(--text);
  font: inherit;
  text-align: left;
  cursor: pointer;
}

.search-hit.is-active { background: var(--surface-3); }

.search-hit-main { display: flex; flex-direction: column; min-width: 0; }

.search-hit-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.search-hit-sub { font-size: var(--text-4); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

.search-hit-side { display: flex; align-items: center; gap: var(--sp-2); flex: none; }

.search-note { margin: var(--sp-2); color: var(--text-dim); font-size: var(--text-3); }

.pill.st-muted { color: var(--text-dim); background: var(--surface-2); }

main { padding: var(--sp-5); max-width: 1500px; margin: 0 auto; }
.view { display: grid; gap: var(--sp-4); }

.view-head { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); }

/* A view head that carries a filter line under its title. Wrapping is what puts
   the line on its own row: the bar asks for the whole width, so it cannot share
   one with the heading, and the heading keeps the left edge either way. */
.view-head-filters { flex-wrap: wrap; }

.filter-bar {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  flex: 1 1 100%;
  flex-wrap: wrap;
}

/* The bar is a row of small controls, not a form: every field in it is set to the
   height of a chip, which is what the global `width: 100%` and 8px of padding
   would otherwise overrule. They stay flexible so that a narrow window stacks
   them instead of pushing the last one off the edge. */
.filter-bar > select,
.filter-bar > input[type='search'] {
  width: auto;
  flex: 1 1 150px;
  min-width: 0;
  max-width: 260px;
  padding: var(--sp-1) var(--sp-3);
  font-size: var(--text-4);
}

.filter-bar > input[type='search'] { flex-basis: 190px; }

/* ---------------------------------------------------------------- widgets */

/* The header, as a grid of tiles. Twelve columns, fixed, and the tiles are sized
   in columns out of those twelve — the sizes are in widgets.js, beside the widget
   they belong to.
   Twelve, and fixed, rather than `auto-fit`, which is what this was first: with
   `auto-fit` the strip grew a column whenever the window was wide enough for one
   more, so the same five tiles were 193px wide in one window and 161px in the
   next, and never matched the cards below them. With a fixed count a tile is the
   same width everywhere, and the three sizes add up: the ring takes four, a figure
   two, everything with a list in it four, so the header a fresh account starts
   with is four plus four times two — exactly one row, no half-filled line, and no
   tile stretched to fill one.
   The tile is the grid item and brings no surface of its own: each widget's own
   markup keeps the look it had when it stood directly in the header — a `.stat`, a
   `.summary-card`, a `.widget-card` — so the default set is the header that was
   always there, and only the arrangement is new. */
.widgets {
  display: grid;
  grid-template-columns: repeat(12, minmax(0, 1fr));
  gap: var(--gap);
  align-items: stretch;
}

.widget { display: grid; min-width: 0; }

/* How much of the row a tile takes, named for what is inside it rather than for
   how big it is: a hero is the one tile whose whole job is to be looked at first,
   a figure is a number and a label, and everything else is a list, a map or a row
   of buttons. */
.widget-hero { grid-column: span 4; }
.widget-wide { grid-column: span 4; }
.widget-fig { grid-column: span 2; }

/* A tile is as tall as its neighbours, so the row has one baseline rather than
   four. The body is what stretches; the widget inside it keeps its own padding. */
.widget-body { display: grid; min-width: 0; }
.widget-body > .stat,
.widget-body > .summary-card,
.widget-body > .widget-card,
.widget-body > .widget-clock { height: 100%; }

/* A figure sits at the top of its tile, not in the middle of it. The row of tiles
   is read across, and a number is looked for where the number before it was — so
   the labels have to be on one line. Centred, they are not: the tiles are the same
   height and the contents are not, so a tile with a curve under its number starts
   its label a few pixels lower than one without, and the header stops reading as a
   row and starts reading as five boxes that happen to be near each other. The rest
   of the header does not need this — a list fills its tile top-down anyway — and the
   same figures stand in the sensor window, where a card is as tall as its contents
   and the two readings are the same. */
.widget-body > .stat { align-content: start; }

/* Every tile has the same surface, the clock included. It used to be the one tile
   without one, which made it read as a hole in the header rather than as a tile
   that happens to hold a time. */
.summary-card,
.stat,
.widget-clock,
.panel,
.card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
}

.summary-card {
  display: flex;
  align-items: center;
  gap: var(--sp-4);
  padding: var(--pad);
}

.summary-ring { flex: 0 0 auto; }
.summary-counts { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--sp-0); }
.summary-counts .count { color: var(--text-dim); font-size: var(--text-2); }
.summary-counts .count-n { color: var(--text); font-weight: 650; font-variant-numeric: tabular-nums; }
.count.s-ok .count-n { color: var(--ok); }
.count.s-crit .count-n { color: var(--crit); }
.count.s-warn .count-n { color: var(--warn); }
.count.s-paused .count-n { color: var(--paused); }

.stat { display: grid; align-content: center; gap: var(--sp-1); padding: var(--pad); }
.stat-label { color: var(--text-dim); font-size: var(--text-4); text-transform: uppercase; letter-spacing: 0.04em; }
.stat-value { font-size: var(--text-1); font-weight: 600; font-variant-numeric: tabular-nums; }
.stat-value.small { font-size: var(--text-2); font-weight: 500; }

/* The two things under the number: what it usually is, and which way it has been
   going. The comparison is quieter than the number — it is the context for the
   figure, not a second figure — and the curve is as wide as the tile, because a
   shape is the one thing here that is worth all the width it can get. */
.stat-sub { font-size: var(--text-4); }
.stat-spark { display: block; margin-top: var(--sp-0); }
.stat-spark svg { display: block; width: 100%; height: 24px; }

/* The tiles that are not a figure: a list, a clock, a map, a row of buttons. */
.widget-card {
  display: grid;
  align-content: start;
  gap: var(--sp-2);
  padding: var(--pad);
}

.widget-head {
  margin: 0;
  color: var(--text-dim);
  font-size: var(--text-4);
  text-transform: uppercase;
  letter-spacing: 0.04em;
}

.widget-list { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--sp-0); }

.widget-row {
  display: flex;
  align-items: baseline;
  gap: var(--sp-3);
  width: 100%;
  padding: var(--sp-1) var(--sp-2);
  border: 1px solid transparent;
  border-radius: var(--radius-sm);
  background: none;
  color: inherit;
  font: inherit;
  font-size: var(--text-3);
  text-align: left;
}

button.widget-row { cursor: pointer; }
button.widget-row:hover { background: var(--surface-2); }
.widget-row-main { display: grid; min-width: 0; flex: 1; }
.widget-row-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.widget-row-sub { font-size: var(--text-4); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.widget-row-side { flex: none; font-variant-numeric: tabular-nums; }

.widget-clock { display: grid; align-content: center; gap: var(--sp-0); padding: var(--pad); }
.clock-time { font-size: var(--text-0); font-weight: 600; font-variant-numeric: tabular-nums; line-height: 1.1; }
.clock-date { font-size: var(--text-3); }

/* The map in a header: a picture, not a control. Its own chrome is hidden here
   rather than in map.js, because map.js draws the full view, where all of it is
   wanted. */
.widget-map-card { gap: var(--sp-2); }
.widget-map { position: relative; height: 150px; border-radius: var(--radius-sm); overflow: hidden; }
.widget-map .map-controls,
.widget-map .map-legend,
.widget-map .map-attribution { display: none; }
/* The line naming the sites that could not be drawn is put beside the host by
   map.js. In the full view that is the right place for it; in a header tile it
   is a paragraph about the map sitting inside the map. */
.widget-map-card .map-unplaced { display: none; }
.widget-map canvas { pointer-events: none; }

.widget-links { display: flex; flex-wrap: wrap; gap: var(--sp-2); }

.widget-empty { margin: 0; padding: var(--pad); }

/* The same list as a checklist, on the settings page: the name spelled out, a
   box to switch it on, two arrows to move it. The arrows keep their place when
   they are disabled, so the rows do not jump as widgets come and go. */
.widget-picks { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--sp-0); }

.widget-pick {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  padding: var(--sp-0) var(--sp-2);
  border-radius: var(--radius-sm);
}

.widget-pick:hover { background: var(--surface-2); }
.widget-pick-label { display: flex; align-items: center; gap: var(--sp-2); flex: 1; min-width: 0; cursor: pointer; font-size: var(--text-3); }
/* The same row in the picker window, where it is a button rather than a label:
   choosing a widget there is an action, not a switch to be ticked. It is spelled
   out as a button so it keeps the label's looks instead of the browser's. */
button.widget-pick-label {
  padding: 0;
  border: 0;
  background: none;
  color: inherit;
  font: inherit;
  font-size: var(--text-3);
  text-align: left;
}

.widget-pick-icon { display: inline-flex; width: 16px; height: 16px; color: var(--text-dim); }
.widget-pick-icon svg { width: 100%; height: 100%; }
.widget-pick .icon-btn { padding: var(--sp-0) var(--sp-2); font-size: var(--text-5); }

/* --------------------------------------------------- arranging the widgets */

/* The top bar has one line, and the buttons that arrange the header are drawn as
   the same segmented control as the layout switcher beside them — see drawTools in
   widgets.js. Nothing else is needed here: the switcher's own rules already give
   the pill, the pressed state and the icon size. */
.widgets-tools { display: flex; align-items: center; }

/* The dashed frame is what says "this can be moved", and it is the only thing
   that marks the tiles while the strip is being arranged — there is no second
   copy of the header to drag from. */
.widget.is-editing {
  outline: 1px dashed var(--border-strong);
  outline-offset: 3px;
  border-radius: var(--radius);
}

.widget-bar {
  display: flex;
  align-items: center;
  gap: var(--sp-1);
  padding: var(--sp-0);
  color: var(--text-dim);
  font-size: var(--text-4);
}

.widget-grip { cursor: grab; letter-spacing: -1px; }
.widget-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.widget-bar-space { flex: 1; }
.widget-bar .icon-btn { padding: var(--sp-0) var(--sp-1); font-size: var(--text-5); }

.widget.is-dragging { opacity: 0.6; }
.widget.is-dragging .widget-grip { cursor: grabbing; }


/* ------------------------------------------------------------------- cards */

/* The groups are a list of headings with cards under them, and the spacing has to
   say which cards belong to which heading. The grid's own gap is the small step —
   between a heading and its first row of cards — and the heading carries the large
   one above it, so the distance to the next group is the sum of the two. Before
   this, a heading sat exactly as far from its own cards as from the next group's. */
.groups { display: grid; gap: var(--sp-3); }

/* One heading for a group, whichever view draws it: the cards, the list, the tree
   and the wall say the same thing in the same size, and only the indentation
   differs — the tree draws its heading inside a place, so it is inset to where
   that place's rows begin. These were three sets of numbers that nearly agreed:
   0.85 against 0.75, --text-dim against --text-faint, .06em against .05em. Nearly
   agreeing is what reads as sloppiness without anybody being able to say why. */
.group-title,
.group-label,
.wall-head {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  color: var(--text-dim);
  font-size: var(--text-4);
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.05em;
}
.group-title { margin: 0; }
.groups > .group-title { margin-top: var(--sp-4); }
.groups > .group-title:first-child { margin-top: 0; }
.group-count { margin-left: auto; color: var(--text-faint); font-weight: 400;
  text-transform: none; letter-spacing: 0; }

.cards {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
  gap: var(--gap);
}

.card {
  position: relative;
  padding: var(--pad);
  display: grid;
  gap: var(--sp-2);
  cursor: pointer;
  transition: border-color 0.15s, transform 0.15s;
}

.card:hover { border-color: var(--border-strong); transform: translateY(-1px); }

/* A status stripe on the leading edge: readable at a glance, and it survives
   being looked at by someone who cannot separate red from green. */
.card::before {
  content: '';
  position: absolute;
  inset: 0 auto 0 0;
  width: 3px;
  border-radius: var(--radius) 0 0 var(--radius);
  background: var(--unknown);
}

.card.st-up::before { background: var(--ok); }
.card.st-down::before { background: var(--crit); }
.card.st-warn::before { background: var(--warn); }
.card.st-paused::before { background: var(--paused); }
/* A ring rather than a repainted bar: the bar on the left is the status, and a card
   inside a window is still whatever the probe found. The ring is what makes a grid
   readable at a glance without taking that away. */
.card.in-excused { box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--excuse) 55%, transparent); }
.card.in-excused::before { box-shadow: 0 0 0 1px color-mix(in srgb, var(--excuse) 70%, transparent); }
/* Which colour is which reason, set once from the class the card carries. */
.card.in-maintenance, .tile.in-maintenance, .tree-row.in-maintenance, .site-marker.in-maintenance { --excuse: var(--maint); }
.card.in-dependency, .tile.in-dependency, .tree-row.in-dependency, .site-marker.in-dependency { --excuse: var(--dep); }
.card.is-down { border-color: color-mix(in srgb, var(--crit) 45%, var(--border)); }
/* Example sensors get a quieter tint than a down card: the mark says where the
   row came from, not that anything is wrong with it. */
.card.is-example { border-color: color-mix(in srgb, var(--accent) 30%, var(--border)); }

/* One line, always.
 *
 * The head holds the status pill, the symbol, the name and up to two badges —
 * example data and the maintenance marker — and it used to wrap: a long name with
 * two badges put the badges on a second line, so two cards side by side were
 * different heights and the grid went ragged. The name takes what is left and is
 * cut off with an ellipsis when that is not enough, and it says its whole self in
 * the tooltip. A name is the one thing on a card that is worth reading twice, so
 * it is the thing that gets the tooltip; nothing else here needs one. */
.card-head { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: nowrap; }
.card-name {
  font-size: var(--text-2);
  flex: 1 1 auto;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.card-target {
  font-family: var(--mono);
  font-size: var(--text-4);
  color: var(--text-dim);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.card-message { font-size: var(--text-3); color: var(--text-dim); overflow-wrap: anywhere; }
.card-spark { margin: var(--sp-0) 0; }
/* The footer is two lines of one kind of thing: what the numbers are, and what
   can be done about them. The buttons are held back to a third of their strength
   and come forward when the card is pointed at, hovered or reached by keyboard.
   They are never hidden — a card whose buttons appear from nowhere is a card that
   has to be learnt — and the row keeps its height either way, so nothing moves
   under the pointer. The acknowledge button is the exception and stays at full
   strength: it says an outage has been dealt with, which is worth seeing from
   across the room rather than on approach. */
.card-foot {
  display: flex;
  align-items: center;
  gap: var(--sp-3);
  flex-wrap: wrap;
  padding-top: var(--sp-2);
  border-top: 1px solid var(--border);
}
.card-meta { margin-left: auto; font-size: var(--text-4); }
.card-stats { display: flex; gap: var(--sp-3); font-size: var(--text-4); color: var(--text-dim); }
.card-stats b { color: var(--text); font-variant-numeric: tabular-nums; }
.card-actions { display: flex; gap: var(--sp-0); opacity: 0.35; transition: opacity 0.15s; }
.card:hover .card-actions,
.card:focus-within .card-actions,
.card.is-down .card-actions,
.tile:hover .card-actions,
.tile:focus-within .card-actions,
.tree-row:hover .card-actions,
.tree-row:focus-within .card-actions { opacity: 1; }
.card-actions .icon-btn.is-acked { opacity: 1; }
@media (hover: none) {
  /* A touch screen has no hovering, so on one the buttons are simply there. */
  .card-actions { opacity: 1; }
}

/* -------------------------------------------------------------- list layout */

.sensors-table, .incidents-table, .results-table {
  width: 100%;
  border-collapse: collapse;
  font-size: var(--text-3);
}

.sensors-table th, .incidents-table th, .results-table th {
  text-align: left;
  font-size: var(--text-5);
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--text-dim);
  font-weight: 600;
  padding: var(--sp-2) var(--sp-3);
  border-bottom: 1px solid var(--border);
  white-space: nowrap;
}

.sensors-table td, .incidents-table td, .results-table td {
  padding: var(--sp-2) var(--sp-3);
  border-bottom: 1px solid var(--border);
  vertical-align: middle;
}

.sensors-table tbody tr, .incidents-table tbody tr { cursor: pointer; }
.sensors-table tbody tr:hover, .incidents-table tbody tr:hover { background: var(--surface-2); }
.cell-name { display: grid; }
.cell-name .muted { font-family: var(--mono); font-size: var(--text-4); }

.sensors-table tr.st-down { background: color-mix(in srgb, var(--crit) 6%, transparent); }
.sensors-table tr.st-warn { background: color-mix(in srgb, var(--warn) 6%, transparent); }
.sensors-table tr.in-excused { box-shadow: inset 3px 0 0 var(--excuse); }
.row-crit { background: color-mix(in srgb, var(--crit) 7%, transparent); }

.table-wrap {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  overflow: auto;
}

/* The tables in Settings, which are a different animal from the three above.
   Those are the dashboard: a reading, a status and a sparkline, rebuilt once a
   minute and read at a glance, and their rows are clickable because clicking one
   opens the sensor. These are a list of things somebody configured — sites, user
   accounts — where the row is text and the thing to do with it is at the end of
   the line. So they get a head, rules between the rows, and a right-hand column
   that holds still while the eye runs down it.

   `vertical-align: middle` is not decoration here: a row with a two-line first
   cell and a one-line button puts the button at the top without it, and a column
   of buttons that do not line up reads as a broken table. */
.data-table {
  width: 100%;
  border-collapse: collapse;
  font-size: var(--text-3);
}

.data-table th {
  padding: var(--sp-2) var(--sp-3);
  text-align: left;
  font-size: var(--text-5);
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-dim);
  white-space: nowrap;
  border-bottom: 1px solid var(--border);
}

.data-table td {
  padding: var(--sp-2) var(--sp-3);
  vertical-align: middle;
  border-bottom: 1px solid var(--border);
}

/* The last row's rule would double the border the .table-wrap already draws. */
.data-table tbody tr:last-child td { border-bottom: 0; }
.data-table tbody tr:hover { background: var(--surface-2); }
.data-table th:first-child, .data-table td:first-child { padding-left: var(--sp-4); }
.data-table th:last-child, .data-table td:last-child { padding-right: var(--sp-4); }

/* A count in a table is read by its column, not by its row, so the digits have
   to sit still: same width everywhere, aligned on the right edge. */
.data-table .num {
  text-align: right;
  font-variant-numeric: tabular-nums;
  color: var(--text-dim);
}

/* And the actions column is the same idea at the other end of the row: the
   buttons of a row, all of them in the same place, so the eye can go down the
   column and act.

   `display: table-cell` is the load-bearing line. The `.row-actions` rule up in
   the accounts section gives its cell `display: flex`, which is right for the
   card it was written for and wrong here: a `td` with a display of anything but
   table-cell is not a cell any more, and the browser takes it out of the table
   layout — the row stops lining up with its own head and the buttons float
   against the row above. The gap between the buttons is a margin instead, and
   `white-space: nowrap` plus a width of 1% is what keeps the column at the width
   of its buttons and lets the name column have everything else. */
.data-table .row-actions {
  display: table-cell;
  text-align: right;
  white-space: nowrap;
  width: 1%;
}

.data-table .row-actions .btn {
  padding: var(--sp-1) var(--sp-2);
  font-size: var(--text-4);
}

.data-table .row-actions .btn + .btn { margin-left: var(--sp-2); }

/* ------------------------------------------------------------------- pills */

.pill {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-2);
  padding: var(--sp-0) var(--sp-2) var(--sp-0) var(--sp-2);
  border-radius: 999px;
  font-size: var(--text-5);
  font-weight: 600;
  white-space: nowrap;
  color: var(--unknown);
  background: color-mix(in srgb, var(--unknown) 16%, transparent);
  border: 1px solid color-mix(in srgb, var(--unknown) 35%, transparent);
}

.pill .dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
/* A status pill and the badge that may follow it travel as one box, so that a row
   laid out as a grid counts them once — see statusPill. */
.pill-group { display: inline-flex; align-items: center; gap: var(--sp-1); min-width: 0; }
/* The "Example" chip. Quieter than a status pill on purpose — it is a fact about
   where the sensor came from, not a state that can change. */
.pill.badge-example {
  color: var(--accent);
  background: color-mix(in srgb, var(--accent) 14%, transparent);
  border-color: color-mix(in srgb, var(--accent) 34%, transparent);
  font-weight: 500;
}
.pill.st-up { color: var(--ok); background: color-mix(in srgb, var(--ok) 15%, transparent); border-color: color-mix(in srgb, var(--ok) 38%, transparent); }
.pill.st-down { color: var(--crit); background: color-mix(in srgb, var(--crit) 15%, transparent); border-color: color-mix(in srgb, var(--crit) 40%, transparent); }
.pill.st-warn { color: var(--warn); background: color-mix(in srgb, var(--warn) 15%, transparent); border-color: color-mix(in srgb, var(--warn) 38%, transparent); }
.pill.st-paused { color: var(--paused); background: color-mix(in srgb, var(--paused) 15%, transparent); }
.pill.st-maint { color: var(--maint); background: color-mix(in srgb, var(--maint) 15%, transparent); border-color: color-mix(in srgb, var(--maint) 40%, transparent); }
.pill.st-dep { color: var(--dep); background: color-mix(in srgb, var(--dep) 15%, transparent); border-color: color-mix(in srgb, var(--dep) 40%, transparent); }
.tree-row.in-excused { background: color-mix(in srgb, var(--excuse) 7%, transparent); }
.site-marker.in-excused { outline: 2px solid var(--excuse); outline-offset: 1px; }
.pill.st-unknown { color: var(--unknown); }

/* ------------------------------------------------------------------ charts */

.chart { display: block; max-width: 100%; }
.chart-empty, .strip-empty { padding: var(--sp-5); text-align: center; }

.chart .grid { stroke: var(--border); stroke-width: 1; }
.chart .axis { fill: var(--text-faint); font-size: var(--text-5); font-family: var(--font); }
.chart .line { fill: none; stroke: var(--accent); stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; }
.chart .band { fill: color-mix(in srgb, var(--accent) 20%, transparent); stroke: none; }
.chart .dot { fill: var(--accent); }
.chart .hit { fill: transparent; }
.chart .hit:hover { fill: color-mix(in srgb, var(--accent) 12%, transparent); }

.spark-line { fill: none; stroke: var(--accent); stroke-width: 1.5; stroke-linejoin: round; }
.spark-dot { fill: var(--accent); }
.spark-fail { stroke: var(--crit); stroke-width: 2; }
.spark-empty { stroke: var(--border); stroke-width: 1; stroke-dasharray: 3 3; }

.strip { width: 100%; }
.bar { fill: var(--unknown); }
.bar.u-ok { fill: var(--ok); }
.bar.u-warn { fill: var(--warn); }
.bar.u-bad { fill: var(--bad); }
.bar.u-crit { fill: var(--crit); }
.bar.u-empty { fill: var(--surface-3); }
.bar-hit { fill: transparent; }
.bar-hit:hover { fill: color-mix(in srgb, var(--text) 18%, transparent); }

.donut-track { stroke: var(--surface-3); }
.donut-seg.s-ok { stroke: var(--ok); }
.donut-seg.s-crit { stroke: var(--crit); }
.donut-seg.s-warn { stroke: var(--warn); }
.donut-seg.s-unknown { stroke: var(--unknown); }
.donut-seg.s-paused { stroke: var(--paused); }

/* ------------------------------------------------------------------ sheets */

.sheet {
  width: min(880px, calc(100vw - 32px));
  max-height: calc(100vh - 60px);
  padding: 0;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface);
  color: var(--text);
  box-shadow: var(--shadow);
}

.sheet::backdrop { background: rgb(6 10 16 / 62%); backdrop-filter: blur(2px); }

/* A short list of one-line choices does not need the width a sensor's history
   does, and a window that wide for eleven names is mostly empty. */
.sheet-narrow { width: min(420px, calc(100vw - 32px)); }
.sheet-note { margin: var(--sp-4) var(--sp-4) 0; }
.sheet-narrow .widget-picks { padding: var(--sp-3) var(--sp-3) var(--sp-4); }

/* The detail sheet is focused when it opens, on purpose — see openDetail. The ring
   that :focus-visible would draw is a rounded accent rectangle around the whole
   panel, which is not a cue anybody can act on; everything inside it that can
   actually be pressed still gets one. */
.sheet:focus-visible { outline: none; }

.sheet-head {
  position: sticky;
  top: 0;
  display: flex;
  align-items: center;
  gap: var(--sp-3);
  padding: var(--sp-4) var(--sp-4);
  background: var(--surface);
  border-bottom: 1px solid var(--border);
  z-index: 2;
}

.sheet-head h2 { margin-right: auto; overflow-wrap: anywhere; }
.sheet-body { padding: var(--sp-4); display: grid; gap: var(--gap); }
.sheet-actions { padding: var(--sp-3) var(--sp-4) 0; }
.sheet-foot {
  position: sticky;
  bottom: 0;
  display: flex;
  gap: var(--sp-2);
  justify-content: flex-end;
  padding: var(--sp-4) var(--sp-4);
  background: var(--surface);
  border-top: 1px solid var(--border);
}

#detail-body { padding: var(--sp-4); display: grid; gap: var(--sp-3); }
.detail-sub { font-family: var(--mono); font-size: var(--text-4); }

/* The offer to bring the open window up to date, which appears once a probe has
   landed behind it. It is a button because pressing it is what redraws the body —
   the window deliberately does not redraw itself, or the curve would rescale
   under whoever is reading it. Full width and quiet, so that it reads as a note
   that happens to be pressable rather than as the window's main action. */
.detail-notice {
  display: block;
  width: calc(100% - 36px);
  margin: var(--sp-3) var(--sp-4) 0;
  padding: var(--sp-2) var(--sp-3);
  border: 1px solid color-mix(in srgb, var(--accent) 38%, transparent);
  border-radius: var(--radius-sm);
  background: color-mix(in srgb, var(--accent) 10%, transparent);
  color: var(--text);
  font: inherit;
  font-size: var(--text-3);
  text-align: center;
  cursor: pointer;
}

.detail-notice:hover { background: color-mix(in srgb, var(--accent) 18%, transparent); }
.detail-stats {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
  gap: var(--gap);
}
.section-title {
  margin-top: var(--sp-2);
  color: var(--text-dim);
  font-size: var(--text-4);
  text-transform: uppercase;
  letter-spacing: 0.05em;
}

.banner {
  padding: var(--sp-2) var(--sp-3);
  border-radius: var(--radius-sm);
  font-size: var(--text-3);
}

.banner.crit {
  color: var(--crit);
  background: color-mix(in srgb, var(--crit) 12%, transparent);
  border: 1px solid color-mix(in srgb, var(--crit) 38%, transparent);
}

.range-picker { display: flex; gap: var(--sp-2); flex-wrap: wrap; }

.chip {
  padding: var(--sp-1) var(--sp-3);
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--surface-2);
  color: var(--text-dim);
  font: inherit;
  font-size: var(--text-4);
  cursor: pointer;
}

.chip:hover { color: var(--text); }
.chip.active { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }

/* ------------------------------------------------------------------- forms */

label.field { display: grid; gap: var(--sp-1); align-content: start; }
label.field > span { font-size: var(--text-3); color: var(--text-dim); font-weight: 500; }

input[type='text'], input[type='password'], input[type='number'], input[type='search'], select, textarea {
  width: 100%;
  min-height: var(--ctl-h);
  padding: var(--sp-1) var(--sp-3);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface-2);
  color: var(--text);
  font: inherit;
}

textarea { font-family: var(--mono); font-size: var(--text-3); resize: vertical; }
input:disabled, select:disabled { opacity: 0.55; }

.hint { color: var(--text-faint); font-size: var(--text-4); line-height: 1.4; }

.grid-2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: var(--gap); }
.grid-3 { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: var(--gap); }

/* A checkbox that is a field like any other: label.field stacks its children, which
   is right above an input box and wrong next to a tick, so this one lies flat. */
label.field.check { display: flex; align-items: center; gap: var(--sp-2); }
label.field.check input { width: auto; }
label.field.check > span { color: var(--text); font-size: var(--text-3); }

.sensor-row { display: flex; align-items: center; gap: var(--sp-2); font-size: var(--text-3); }
.sensor-row input { width: auto; }

.channels-pick {
  display: grid;
  gap: var(--sp-2);
  margin: 0;
  padding: var(--pad);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
}

.channels-pick legend { padding: 0 var(--sp-2); color: var(--text-dim); font-size: var(--text-3); }

/* The recipe under a channel type's fields. It is not a fold and not a hint: it is
   the part of the dialog that tells somebody who has never made a Telegram bot
   where the token and the chat id come from, and they cannot know to open it. */
.channel-setup {
  margin-top: var(--pad);
  padding: var(--pad-sm) var(--pad);
  border-left: 2px solid var(--accent);
  background: var(--surface-2);
  border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
}
.channel-setup h4 { margin: 0 0 var(--sp-2); font-size: var(--text-3); color: var(--text-dim); font-weight: 500; }
.channel-setup ol { margin: 0; padding-left: 1.15em; display: grid; gap: var(--sp-1); }
.channel-setup li { font-size: var(--text-4); line-height: 1.45; color: var(--text-dim); }

.options { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: var(--pad-sm); }
.options summary { cursor: pointer; font-size: var(--text-3); color: var(--text-dim); }
.options > div { margin-top: var(--pad-sm); }

.form-actions { display: flex; gap: var(--sp-2); align-items: end; }
.form-error { color: var(--crit); font-size: var(--text-3); min-height: 1.1em; }

/* The password form is a .grid-2 like every other pair of fields, but a button and
   an error line are not fields. Left alone, auto-fit puts the button in the next
   free column beside the input it belongs to — and at four columns wide that is a
   "Change password" sitting to the right of the new-password box, which reads as a
   third field. Given the whole row, it stands under the two fields it acts on,
   which is what a save button does in every other panel. */
#password-form .form-actions,
#password-form .form-error { grid-column: 1 / -1; }

/* Every control is one height. A button, an input, a select and the icon buttons
   that sit beside them in a row are all --ctl-h tall, so that a line of them lines
   up instead of stepping up and down by the few pixels each font metric happened to
   add. It was written as padding: 8px 14px on a button and 8px 10px in a field,
   which is 37 pixels of box for both and looks like two different sizes anyway. */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: var(--ctl-h);
  padding: var(--sp-1) var(--sp-3);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface-2);
  color: var(--text);
  font: inherit;
  font-weight: 500;
  cursor: pointer;
  white-space: nowrap;
}

.btn:hover { border-color: var(--border-strong); background: var(--surface-3); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
.btn-primary:hover { background: color-mix(in srgb, var(--accent) 85%, var(--text)); }
.btn-ghost { background: transparent; }
.btn-danger { color: var(--crit); border-color: color-mix(in srgb, var(--crit) 40%, var(--border)); }
.btn-danger:hover { background: color-mix(in srgb, var(--crit) 12%, transparent); }
.btn-block { width: 100%; justify-content: center; }

/* A button that carries an icon lays the two out side by side and keeps them
   apart. Only a button that has one: the rest of the app is full of these, and
   their plain box is what every other measurement in here is taken against. The
   icon is what is left of such a button when the word goes, so it holds its size
   and refuses to shrink. */
.btn:has(svg) { display: inline-flex; align-items: center; gap: var(--sp-2); }
.btn svg { width: 16px; height: 16px; flex: none; }

.icon-btn {
  width: var(--ctl-h);
  height: var(--ctl-h);
  display: inline-grid;
  place-items: center;
  border: 1px solid transparent;
  border-radius: var(--radius-sm);
  background: transparent;
  color: var(--text-dim);
  font: inherit;
  font-size: var(--text-2);
  line-height: 1;
  cursor: pointer;
}

.icon-btn:hover { background: var(--surface-2); color: var(--text); border-color: var(--border); }
.icon-btn:disabled { opacity: 0.35; cursor: not-allowed; }

.switch { display: flex; align-items: center; gap: var(--sp-2); font-size: var(--text-3); color: var(--text-dim); }
.switch input { width: auto; }

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

#settings-body { display: grid; gap: var(--gap); }

/* The settings page is divided into categories, and one of them is on screen at
   a time. The tab that is switched off in simple mode because all of its panels
   are folded away is hidden by the script, which is the only place that knows
   both the mode and what each panel carries. Nothing here hides anything with
   `!important`: the `hidden` attribute does that, and it is the one rule that
   has to win. */

/* The tabs. A row of buttons that switch rather than scroll, so the row is the
   page's table of contents and its position marker at once. It scrolls sideways
   rather than wrapping, so that it is the same height at every width. */
.settings-tabs {
  display: flex;
  gap: var(--sp-2);
  overflow-x: auto;
  padding: var(--sp-1);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface);
}

.tab-btn {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  flex: none;
  padding: var(--sp-2) var(--sp-3);
  border: 0;
  border-radius: 999px;
  background: transparent;
  color: var(--text-dim);
  font: inherit;
  font-size: var(--text-3);
  cursor: pointer;
  white-space: nowrap;
}

.tab-btn svg { width: 15px; height: 15px; }
.tab-btn:hover { background: var(--surface-2); color: var(--text); }
/* `aria-selected`, not `aria-pressed`: these are tabs, and a screen reader
   announces that differently — "selected", in a set of five, rather than a
   toggle that is on. The rule is written on the attribute the state is actually
   in, so there is no second copy of it to drift. */
.tab-btn[aria-selected='true'] { background: var(--surface-3); color: var(--text); }

.panel { padding: var(--pad) calc(var(--pad) + 2px); display: grid; gap: var(--sp-4); }
/* A panel's heading is now a title rather than a label. It used to be 0.8rem of
   uppercase grey — the same treatment .section-title gets for the headings inside
   a panel — which made it read as one more line of the panel instead of as the
   thing that opens it. It is one size up and in the text colour; the headings
   inside a panel stay where they were, so the two levels are told apart by weight
   now and not by both being small and grey. */
.panel h2 { color: var(--text); font-size: var(--text-2); font-weight: 600; letter-spacing: 0; }

/* The heading and the sentence that says what the panel is for, held together.
   Without the wrapper the panel's own 14px gap would stand between them, and a
   hint that far from its heading reads as the first line of the panel's content
   rather than as part of its title. */
.panel-head { display: grid; gap: var(--sp-1); }
.panel-hint { margin: 0; color: var(--text-dim); font-size: var(--text-3); }

.kv { display: grid; grid-template-columns: max-content 1fr; gap: var(--sp-2) var(--sp-4); margin: 0; font-size: var(--text-3); }
.kv dt { color: var(--text-dim); }
.kv dd { margin: 0; font-family: var(--mono); font-size: var(--text-3); overflow-wrap: anywhere; }

.key-row { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
.key-row code {
  flex: 1 1 260px;
  padding: var(--sp-2) var(--sp-3);
  background: var(--surface-2);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  font-family: var(--mono);
  font-size: var(--text-3);
  overflow-wrap: anywhere;
}

.channel.is-paused { opacity: 0.7; }
.channel .card-actions { justify-content: flex-end; }

/* The foot of the settings page. It is the one part of that page which is not
   rebuilt whenever a panel changes — it lives in index.html, outside
   #settings-body — so it is styled as a footer and not as a panel: a rule across
   the top rather than a surface of its own, because there is nothing to act on
   here, only something to read and something to click. */
.settings-footer {
  display: grid;
  gap: var(--sp-2);
  padding-top: var(--pad);
  border-top: 1px solid var(--border);
}

.settings-links { display: flex; flex-wrap: wrap; gap: var(--sp-2) var(--sp-5); }

/* Dimmed until it is pointed at, the way the rest of this page treats anything
   that is not the thing being worked on. The underline is the border rather than
   text-decoration so that it sits clear of the descenders at this size. */
.settings-links a {
  color: var(--text-dim);
  font-size: var(--text-3);
  text-decoration: none;
  border-bottom: 1px solid var(--border);
}
.settings-links a:hover { color: var(--text); border-bottom-color: currentColor; }

/* ------------------------------------------------------- empty and loading */

/* An empty state, and the line that stands in its place while something loads.
 *
 * Three of these blocks were already in the page — no sensors, no channels, no
 * incidents — and not one of them had a rule: a heading, a sentence and sometimes
 * a button, stacked at the top of a page that was otherwise blank. An empty view
 * that looks like a mistake is read as one, which is why this is a box with an
 * edge: it says the area is empty because there is nothing in it, and the sentence
 * in it says what would put something there. */
.empty {
  display: grid;
  justify-items: center;
  gap: var(--sp-3);
  padding: var(--sp-6) var(--sp-4);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface);
  text-align: center;
  /* A full-width sentence is a hard line to read and an empty state is one
     sentence, so the box is as wide as a comfortable line of text and no wider. */
  max-width: 720px;
  margin-inline: auto;
}
.empty h2 { font-size: var(--text-2); font-weight: 600; }
.empty p { max-width: 54ch; color: var(--text-dim); font-size: var(--text-4); line-height: 1.5; }
.empty .btn { margin-top: var(--sp-1); }

/* The same box inside a panel, a dialog or a grid cell: it stands among other
   panels there, so it is framed like one and does not need the room a whole page
   affords. It is also what a table shows when it has no rows. */
.empty-inline {
  padding: var(--sp-5) var(--sp-4);
  background: var(--surface-2);
}
.empty-inline h2 { font-size: var(--text-3); }

/* Where the thing will be, in the shape it will have: the same box, the same
   padding, one quiet line in the middle, so that nothing jumps when the answer
   arrives. Nothing spins: a spinner on a panel that answers in 200 ms is a
   flicker, and on one that takes two seconds it says less than the word does. */
.loading {
  display: grid;
  place-items: center;
  padding: var(--sp-6) var(--sp-4);
  color: var(--text-faint);
  font-size: var(--text-4);
}
.loading-inline { padding: var(--sp-5) var(--sp-4); }

/* ------------------------------------------------------------------ toasts */

.toasts {
  position: fixed;
  right: 18px;
  bottom: 18px;
  z-index: 60;
  display: grid;
  gap: var(--sp-2);
  max-width: min(380px, calc(100vw - 36px));
}

.toast {
  padding: var(--sp-3) var(--sp-4);
  border: 1px solid var(--border);
  border-left: 3px solid var(--accent);
  border-radius: var(--radius-sm);
  background: var(--surface);
  box-shadow: var(--shadow);
  font-size: var(--text-3);
  overflow-wrap: anywhere;
}

.toast-error { border-left-color: var(--crit); }
.toast-out { opacity: 0; transition: opacity 0.4s; }

/* --------------------------------------------------------- simple/advanced */

/* The simple mode hides whole areas and fields. Doing it in CSS, at one
   selector, is why no render function contains a mode check: a field marked
   .adv-hide disappears the moment the attribute on <html> changes, whatever
   re-drew it last. Nothing is switched off — the values stay in the form and the
   API sees exactly what the advanced mode would send. */
[data-mode='simple'] .adv-hide { display: none; }

/* ------------------------------------------------------------- field help */

.help-dot {
  width: 15px;
  height: 15px;
  padding: 0;
  display: inline-grid;
  place-items: center;
  border: 1px solid var(--border-strong);
  border-radius: 50%;
  background: var(--surface-2);
  color: var(--text-dim);
  font: 600 var(--text-5)/1 var(--font);
  cursor: help;
  vertical-align: middle;
}

.help-dot:hover,
.help-dot[aria-expanded='true'] { color: var(--text); border-color: var(--accent); }

.help-pop {
  position: fixed;
  z-index: 90;
  max-width: min(320px, calc(100vw - 24px));
  display: grid;
  gap: var(--sp-2);
  padding: var(--sp-3) var(--sp-3);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-sm);
  background: var(--surface-3);
  color: var(--text);
  box-shadow: var(--shadow);
  font-size: var(--text-4);
  line-height: 1.45;
}

.help-body { margin: 0; }
.help-eg { margin: 0; display: grid; gap: var(--sp-0); }
.help-eg span { color: var(--text-dim); font-size: var(--text-5); text-transform: uppercase; letter-spacing: 0.05em; }
.help-eg code {
  display: block;
  padding: var(--sp-1) var(--sp-2);
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--bg);
  font-family: var(--mono);
  font-size: var(--text-4);
  overflow-wrap: anywhere;
}

/* --------------------------------------------------------- password meter */

.pw-meter { display: grid; gap: var(--sp-1); margin-top: var(--sp-2); }

.pw-bar { height: 5px; border-radius: 999px; background: var(--surface-3); overflow: hidden; }
.pw-fill {
  display: block;
  width: 0;
  height: 100%;
  border-radius: 999px;
  background: var(--crit);
  transition: width 0.2s ease, background 0.2s ease;
}

.pw-line { display: flex; gap: var(--sp-2); align-items: baseline; font-size: var(--text-4); }
.pw-word { font-weight: 600; color: var(--crit); }
.pw-tip { color: var(--text-faint); }

.pw-meter[data-level='1'] .pw-fill { background: var(--warn); }
.pw-meter[data-level='1'] .pw-word { color: var(--warn); }
.pw-meter[data-level='2'] .pw-fill { background: var(--accent); }
.pw-meter[data-level='2'] .pw-word { color: var(--accent); }
.pw-meter[data-level='3'] .pw-fill { background: var(--ok); }
.pw-meter[data-level='3'] .pw-word { color: var(--ok); }

/* ------------------------------------------------- setups's mode question */

.auth-step { display: grid; gap: var(--sp-4); }
.auth-actions { display: flex; align-items: center; gap: var(--sp-2); }

.mode-pick { margin: 0; padding: 0; border: 0; display: grid; gap: var(--sp-3); }
.mode-pick legend { padding: 0; color: var(--text-dim); font-size: var(--text-3); font-weight: 500; }

.mode-card {
  display: grid;
  grid-template-columns: auto 1fr;
  grid-template-areas: 'radio name' 'radio body';
  gap: var(--sp-0) var(--sp-3);
  padding: var(--sp-3) var(--sp-4);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface-2);
  cursor: pointer;
}

.mode-card:hover { border-color: var(--border-strong); }
.mode-card input { grid-area: radio; width: auto; align-self: start; margin-top: var(--sp-0); }
.mode-card:has(input:checked) { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 10%, var(--surface-2)); }
.mode-name { grid-area: name; font-weight: 600; }
.mode-body { grid-area: body; color: var(--text-dim); font-size: var(--text-4); line-height: 1.4; }

/* ------------------------------------------------------- acknowledgement */

.pill.st-acked {
  color: var(--warn);
  background: color-mix(in srgb, var(--warn) 16%, transparent);
  border-color: color-mix(in srgb, var(--warn) 42%, transparent);
}

.card.is-acked { border-color: color-mix(in srgb, var(--warn) 40%, var(--border)); }
.card.is-acked::before { background: var(--warn); }

.ack-note { color: var(--warn); font-size: var(--text-4); }
.incident-actions { white-space: nowrap; text-align: right; }
.detail-ack { margin-top: var(--sp-3); }
.btn-small { min-height: var(--ctl-h-sm); padding: var(--sp-0) var(--sp-2); font-size: var(--text-4); }

/* ------------------------------------------------------------ diagnostics */

#doctor-result:empty { display: none; }

.doctor-summary { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }

.pill.lv-ok { color: var(--ok); background: color-mix(in srgb, var(--ok) 15%, transparent); border-color: color-mix(in srgb, var(--ok) 38%, transparent); }
.pill.lv-warn { color: var(--warn); background: color-mix(in srgb, var(--warn) 15%, transparent); border-color: color-mix(in srgb, var(--warn) 38%, transparent); }
.pill.lv-fail { color: var(--crit); background: color-mix(in srgb, var(--crit) 15%, transparent); border-color: color-mix(in srgb, var(--crit) 40%, transparent); }

.findings { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--sp-2); }
.finding {
  display: grid;
  grid-template-columns: auto auto 1fr;
  gap: var(--sp-1) var(--sp-3);
  align-items: baseline;
  padding: var(--sp-2) var(--sp-3);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface-2);
  font-size: var(--text-3);
}

.finding-label { font-weight: 600; }
.finding-detail { grid-column: 3; }
.finding-hint { grid-column: 3; font-size: var(--text-4); }

.doctor-text {
  margin: var(--sp-2) 0 0;
  padding: var(--sp-3);
  max-height: 24rem;
  overflow: auto;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--bg);
  font-family: var(--mono);
  font-size: var(--text-4);
  white-space: pre-wrap;
  word-break: break-word;
}

/* ------------------------------------------------------- the guided tour */

/* The layer is a fixed sheet with three children: a click-catcher, the
   highlight ring, and the card. The ring dims everything around its target with
   one very large box-shadow — no SVG, no clip-path, nothing the CSP would have
   to allow. */
.tour-layer { position: fixed; inset: 0; z-index: 100; }
.tour-block { position: absolute; inset: 0; z-index: 0; }

.tour-ring {
  position: absolute;
  z-index: 1;
  pointer-events: none;
  border-radius: 10px;
  box-shadow: 0 0 0 3px var(--accent), 0 0 0 9999px rgb(6 10 16 / 66%);
  transition: left 0.18s ease, top 0.18s ease, width 0.18s ease, height 0.18s ease;
}

.tour-card {
  position: absolute;
  z-index: 2;
  width: min(340px, calc(100vw - 28px));
  display: grid;
  gap: var(--sp-2);
  padding: var(--sp-4) var(--sp-4);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  background: var(--surface);
  color: var(--text);
  box-shadow: var(--shadow);
}

.tour-count { margin: 0; color: var(--text-faint); font-size: var(--text-5); text-transform: uppercase; letter-spacing: 0.06em; }
.tour-title { margin: 0; }
.tour-body { margin: 0; color: var(--text-dim); font-size: var(--text-3); line-height: 1.5; }
/* The three-way offer at the end of the tour. It wraps rather than scrolls:
   the card is narrow and a button nobody can see is not an offer. */
.tour-choice { display: flex; flex-wrap: wrap; gap: var(--sp-2); }
.tour-nav { display: flex; align-items: center; gap: var(--sp-2); margin-top: var(--sp-1); }
.tour-spacer { flex: 1; }

/* --------------------------------------------------------- accounts and 2FA */

/* The account table is the one place in the dashboard where a row carries more
   than a status: a name, a role, a second factor and four actions. It gets its
   own rules rather than reusing the sensor list's, which is built for a name and
   a status and would wrap the buttons onto their own line. */
.users-table td { vertical-align: middle; }
.users-table .user-name { font-weight: 600; }
.user-row.is-disabled { opacity: 0.55; }
.row-actions { display: flex; gap: var(--sp-2); flex-wrap: wrap; justify-content: flex-end; }

/* The QR image comes out of the server in module units — a few hundred pixels
   across — and is scaled down here. A whole-number multiple stays sharp, and
   `pixelated` is what keeps the browser from smoothing the module edges into
   something a phone camera has to guess at. */
.qr-wrap { display: flex; justify-content: center; padding: var(--sp-2) 0; }
.qr {
  width: 220px;
  height: 220px;
  image-rendering: pixelated;
  background: #fff;
  padding: var(--sp-2);
  border-radius: var(--radius);
}

/* The key to type in by hand, for a camera that will not focus. It is long
   enough that breaking it anywhere is better than overflowing the dialog. */
.secret-key {
  display: block;
  padding: var(--sp-3) var(--sp-3);
  font-family: var(--mono);
  font-size: var(--text-2);
  letter-spacing: 0.08em;
  word-break: break-all;
  background: var(--surface-2);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  user-select: all;
}

/* The recovery codes. Shown once, in a locked dialog, so they are spaced and
   monospaced like something meant to be read off a screen and written down. */
.recovery-codes {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--sp-2) var(--sp-4);
  margin: 0;
  padding-left: var(--sp-5);
}
.recovery-codes code {
  font-family: var(--mono);
  font-size: var(--text-2);
  letter-spacing: 0.06em;
  user-select: all;
}

/* Holds a throw-away textarea for the clipboard fallback. Off-screen rather than
   `display: none`: an element that is not rendered cannot be selected. */
.offscreen {
  position: fixed;
  top: -1000px;
  left: -1000px;
  width: 1px;
  height: 1px;
  opacity: 0;
}

@media (max-width: 520px) {
  .recovery-codes { grid-template-columns: 1fr; }
}

/* ------------------------------------------------------- shell: switcher */

/* The layout switcher. Five views of the same sensors have to be reachable
   without a menu, so it is a segmented control: one press, and the choice is
   visible on the control itself rather than hidden behind its own label. */
.segmented {
  display: flex;
  gap: var(--sp-0);
  padding: var(--sp-0);
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--surface);
}

.seg-btn {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  padding: var(--sp-1) var(--sp-3);
  border: 0;
  border-radius: 999px;
  background: transparent;
  color: var(--text-dim);
  font: inherit;
  font-size: var(--text-3);
  font-weight: 500;
  cursor: pointer;
  white-space: nowrap;
}

.seg-btn:hover { background: var(--surface-2); color: var(--text); }
.seg-btn[aria-pressed='true'] {
  background: var(--surface-3);
  color: var(--text);
  box-shadow: inset 0 0 0 1px var(--border-strong);
}
.seg-btn svg { width: 15px; height: 15px; }

/* --------------------------------------------------------- shell: account */

.user-area { position: relative; }

.user-chip {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  padding: var(--sp-1) var(--sp-3) var(--sp-1) var(--sp-1);
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--surface);
  color: var(--text);
  font: inherit;
  cursor: pointer;
}

.user-chip:hover { border-color: var(--border-strong); background: var(--surface-2); }
.user-chip[aria-expanded='true'] { border-color: var(--accent); }

.avatar {
  display: grid;
  place-items: center;
  width: 28px;
  height: 28px;
  border-radius: 50%;
  background: var(--accent);
  color: var(--accent-ink);
  font-size: var(--text-4);
  font-weight: 700;
  text-transform: uppercase;
}

.user-chip-name { font-size: var(--text-3); font-weight: 550; max-width: 120px;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* The menu is appended to the open dialog, or to the body, by user.js, and
   positioned from the chip's own rectangle — so it is `fixed` and knows nothing
   about where in the document it happens to live. */
.user-menu {
  position: fixed;
  z-index: 60;
  min-width: 232px;
  padding: var(--sp-2);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  background: var(--surface-2);
  box-shadow: var(--shadow);
}

.user-menu-head {
  padding: var(--sp-2) var(--sp-3) var(--sp-3);
  border-bottom: 1px solid var(--border);
  margin-bottom: var(--sp-2);
}

.user-menu-name { font-weight: 650; }
.user-menu-role { color: var(--text-dim); font-size: var(--text-4); }

.user-menu-item {
  display: flex;
  align-items: center;
  gap: var(--sp-3);
  width: 100%;
  padding: var(--sp-2) var(--sp-3);
  border: 0;
  border-radius: var(--radius-sm);
  background: transparent;
  color: var(--text);
  font: inherit;
  font-size: var(--text-3);
  text-align: left;
  cursor: pointer;
}

.user-menu-item:hover { background: var(--surface-3); }
.user-menu-item.is-danger { color: var(--crit); }
.user-menu-sep { height: 1px; margin: var(--sp-2) 0; background: var(--border); }

.user-menu-field {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-3);
  padding: var(--sp-2) var(--sp-3);
  font-size: var(--text-3);
  color: var(--text-dim);
}

.user-menu-field select {
  padding: var(--sp-1) var(--sp-2);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface);
  color: var(--text);
  font: inherit;
  font-size: var(--text-3);
}

@media (max-width: 860px) {
  .user-chip-name { display: none; }
  .user-chip { padding: var(--sp-1); }
}

/* ------------------------------------------------------------- tree layout */

/* Two levels, site then group, which is the shape of the thing being described:
   a place holds groups, a group holds sensors. <details> is used rather than a
   hand-rolled expander so that the keyboard, the screen reader and the browser's
   own find-in-page all work without being taught. */
.site-node { border: 1px solid var(--border); border-radius: var(--radius);
  background: var(--surface); overflow: hidden; }

.site-head {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  padding: var(--sp-3) var(--sp-4);
  cursor: pointer;
  list-style: none;
  font-weight: 600;
}

.site-head::-webkit-details-marker { display: none; }
.site-head:hover { background: var(--surface-2); }
.site-node[open] > .site-head { border-bottom: 1px solid var(--border); }

.site-caret { color: var(--text-faint); transition: transform 0.15s ease; }
.site-node[open] > .site-head .site-caret { transform: rotate(90deg); }

.site-count { margin-left: auto; color: var(--text-faint); font-weight: 400;
  font-size: var(--text-4); text-transform: none; letter-spacing: 0; }

.kind-tag {
  padding: var(--sp-0) var(--sp-2);
  border-radius: 999px;
  font-size: var(--text-5);
  font-weight: 600;
  letter-spacing: 0.02em;
  border: 1px solid var(--border-strong);
  color: var(--text-dim);
}

.kind-tag.k-external { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); }

.group-node { padding: var(--sp-1) 0; }
/* The tree's heading stands where the rows under it begin, and those rows are
   inset past the status pill every one of them carries. */
.group-label { padding: var(--sp-3) var(--sp-4) var(--sp-1) var(--sp-5); }

.tree-row {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) 120px 92px auto;
  align-items: center;
  gap: var(--sp-3);
  padding: var(--sp-2) var(--sp-4) var(--sp-2) var(--sp-5);
  border-top: 1px solid var(--border);
}

.site-body > .group-node:first-child .tree-row:first-child { border-top: 0; }
.tree-row:hover { background: var(--surface-2); }
.tree-row .row-name { font-weight: 550; }
.tree-row .row-target { color: var(--text-dim); font-size: var(--text-4); overflow: hidden;
  text-overflow: ellipsis; white-space: nowrap; }
.tree-row .num { font-variant-numeric: tabular-nums; color: var(--text-dim); font-size: var(--text-3); }

/* ------------------------------------------------------------- wall layout */

/* One tile per sensor, sized to be scanned rather than read: the heartbeat is the
   point, so the name and the marks are what the eye lands on. */
.wall-group { margin-bottom: var(--sp-5); }
.wall-head { margin: 0 0 var(--sp-3); }
.wall-count { margin-left: auto; color: var(--text-faint); font-weight: 400;
  text-transform: none; letter-spacing: 0; }

.wall { display: grid; grid-template-columns: repeat(auto-fill, minmax(258px, 1fr)); gap: var(--sp-3); }

.tile {
  display: flex;
  flex-direction: column;
  gap: var(--sp-3);
  padding: var(--sp-3) var(--sp-4);
  border: 1px solid var(--border);
  border-left: 3px solid var(--unknown);
  border-radius: var(--radius);
  background: var(--surface);
  cursor: pointer;
}

.tile:hover { background: var(--surface-2); border-color: var(--border-strong); }
.tile.st-up { border-left-color: var(--ok); }
.tile.st-down { border-left-color: var(--crit); }
.tile.st-warn { border-left-color: var(--warn); }
.tile.in-excused { box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--excuse) 55%, transparent); }
.tile.st-paused { border-left-color: var(--paused); }
.tile.is-down { background: color-mix(in srgb, var(--crit) 7%, var(--surface)); }

.tile-head { display: flex; align-items: center; gap: var(--sp-2); }
.tile-name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tile-meta { display: flex; justify-content: space-between; gap: var(--sp-2);
  color: var(--text-dim); font-size: var(--text-4); font-variant-numeric: tabular-nums; }

/* The heartbeat. One mark per check, oldest on the left, so a run of failures
   reads as a block — which is the thing the eye is looking for. The marks are an
   SVG that stretches to the tile it is in: each check then gets an equal share
   of whatever width there is, rather than a fixed 240 pixels of it. */
.heartbeat { height: 22px; }
.heartbeat svg { display: block; width: 100%; height: 22px; }
.heartbeat .beat { fill: var(--ok); }
.heartbeat .beat.h-fail { fill: var(--crit); }
.heartbeat-empty { color: var(--text-faint); font-size: var(--text-4); }

/* -------------------------------------------------------------- map layout */

.map-host { position: relative; height: min(72vh, 660px); border: 1px solid var(--border);
  border-radius: var(--radius); overflow: hidden; background: var(--surface-2); }

/* The graticule under everything. A place with nothing under it is a puzzle, and a
   place on a grid is a map — which is what makes the markers worth drawing before a
   single coastline has been laid down. */
.map-canvas { position: absolute; inset: 0; cursor: grab;
  background-color: var(--surface-2);
  background-image:
    linear-gradient(color-mix(in srgb, var(--border) 70%, transparent) 1px, transparent 1px),
    linear-gradient(90deg, color-mix(in srgb, var(--border) 70%, transparent) 1px, transparent 1px);
  background-size: 64px 64px;
  touch-action: none;
}
.map-canvas.is-dragging { cursor: grabbing; }

/* The coastlines. The SVG is stretched over the canvas in CSS pixels and knows
   nothing about zoom: map.js writes the path once, in the map's world coordinates,
   and outline.js puts the view into the group's transform. So a zoom is a transform
   on one element instead of a few thousand coordinates rewritten, and the coastline
   stays one pixel wide at every level because the path carries
   `vector-effect: non-scaling-stroke`. Clipping to the canvas is the default here and
   is what should happen — the world is wider than the widget at every zoom. */
.map-outline { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
.map-outline path { fill: var(--map-land); stroke: var(--map-coast);
  stroke-width: 1; stroke-linejoin: round; }

/* The water, over the land and under the borders. A lake is a filled ring and a river
   is a stroked line, both in --map-water: there is one colour for water on this map.
   There are two files of each — the world's and Natural Earth's finer copy of Europe —
   and they draw as one layer, which is why no rule here tells them apart. The fills
   carry no stroke of their own, because the ring they fill is already the shape the
   coastline layer strokes where the land has a hole in it. */
.map-water { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
.map-water path.k-lake { fill: var(--map-water); stroke: none; }
.map-water path.k-river { fill: none; stroke: var(--map-water);
  stroke-width: 1; stroke-linejoin: round; stroke-linecap: round; }

/* Borders are a layer of their own, over the coast and the water and under the tiles.
   `fill: none` is not decoration: a border line does not close, and an open path with
   a fill paints the shape its ends imply — a black half of Asia. The three classes are
   the three files: the countries brighter than the provinces they contain, and the
   districts — which are filled rings and not lines, so they are the one path here that
   is stroked and not left open — dimmer than both, because they are only on screen from
   zoom 8 and a Bezirk border as loud as a country border would make Austria a texture.

   The districts are also the one layer here that is filled, and that fill is the whole
   difference between a district and a country on this map: a country is a shape the
   coast already drew, a district is a shape that only exists because somebody said so.
   It is a tint of a few percent over the land — see the token — so the Austrian view
   reads as a mosaic of districts without any of them becoming a block of colour. The
   fill rule does the rest on its own: six districts have their statutory city cut out as
   a hole, and evenodd leaves those holes unpainted, which is right, because the hole is
   a district of its own. */
.map-borders { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
.map-borders path { fill: none; stroke-width: 1;
  stroke-linejoin: round; stroke-linecap: round; }
.map-borders path.k-country { stroke: var(--map-country); }
.map-borders path.k-district { stroke: var(--map-district); fill: var(--map-district-fill); }
.map-borders path.k-province { stroke: var(--map-border); }

.map-tiles, .map-markers { position: absolute; inset: 0; }

/* Names sit over the tiles and under the pins, in screen coordinates: the boxes are
   positioned by the drawing code and centred on their anchor here, so the width it
   measured for the collision test never decides where a word goes — that number is
   only ever used to decide whether there is room. The shadow is what makes a name
   readable over land and over water both, without an opaque plate that would cover
   the map it is naming. */
.map-labels { position: absolute; inset: 0; pointer-events: none; }
.map-label { position: absolute; white-space: nowrap; color: var(--map-label);
  line-height: 1; transform: translate(-50%, -50%); text-shadow: 0 0 3px rgba(0, 0, 0, .55); }
.map-label.k-country { font-size: var(--text-5); font-weight: 600; letter-spacing: .02em; }
.map-label.k-region { font-size: var(--text-5); opacity: .82; }
/* A city name is the smallest and the quietest of the three, and it is the one that
   arrives in numbers: from zoom 8 a country view holds a dozen of them, and at the
   weight of a province name they would be the loudest thing on the map. It is the same
   size as a province name and dimmer rather than smaller, because smaller than 10px is
   where a name stops being readable at all. */
.map-label.k-city { font-size: var(--text-5); opacity: .68; }
/* The district names are the one set of labels here that is not a name on a place. They
   sit on the middle of the area they name, over the fill that area is painted with, and
   the point of them is that the map underneath still reads: a little smaller than a city
   name, in the district hue at a little over half opacity, so an Austrian view can be
   read as districts and as towns without one layer washing the other out. The text
   shadow the base rule carries is left out — it is there to lift a dark word off dark
   land, and a grey word with a shadow under it stops being grey. */
.map-label.k-district { font-size: var(--text-5); color: var(--map-district-name); opacity: .55;
  text-shadow: none; }
.map-tile { position: absolute; width: 256px; height: 256px; user-select: none;
  pointer-events: none; }

.site-marker {
  position: absolute;
  display: grid;
  place-items: center;
  width: 26px;
  height: 26px;
  margin: -13px 0 0 -13px;
  padding: 0;
  border: 0;
  background: transparent;
  cursor: pointer;
}

/* Local and external differ in shape as well as in colour. Colour is taken —
   it belongs to the status ring — so the shape is what carries "in the house"
   against "somewhere else", and it survives being printed in grey. */
.site-marker::after {
  content: '';
  width: 13px;
  height: 13px;
  border-radius: 50%;
  background: var(--text-dim);
  border: 2px solid var(--surface);
}
.site-marker.k-external::after { border-radius: 3px; transform: rotate(45deg); }
.site-marker.st-up::after { background: var(--ok); }
.site-marker.st-down::after { background: var(--crit); }
.site-marker.st-warn::after { background: var(--warn); }
.site-marker.st-unknown::after { background: var(--unknown); }
.site-marker.st-paused::after { background: var(--paused); }

.site-marker-label {
  position: absolute;
  top: 20px;
  left: 50%;
  transform: translateX(-50%);
  padding: var(--sp-0) var(--sp-2);
  border-radius: var(--radius-sm);
  background: color-mix(in srgb, var(--bg) 82%, transparent);
  color: var(--text);
  font-size: var(--text-5);
  white-space: nowrap;
  pointer-events: none;
}

.map-controls { position: absolute; top: 10px; right: 10px; display: flex;
  flex-direction: column; gap: var(--sp-1); }
.map-controls button { width: 32px; height: 32px; display: grid; place-items: center;
  border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
  background: color-mix(in srgb, var(--surface) 92%, transparent); color: var(--text);
  font: inherit; font-size: var(--text-2); line-height: 1; cursor: pointer; }
.map-controls button:hover { background: var(--surface-3); }

.map-attribution {
  position: absolute;
  right: 0;
  bottom: 0;
  padding: var(--sp-0) var(--sp-2);
  background: color-mix(in srgb, var(--bg) 78%, transparent);
  color: var(--text-dim);
  font-size: var(--text-5);
  border-top-left-radius: var(--radius-sm);
}
.map-attribution a { color: inherit; }

.map-legend { position: absolute; left: 10px; bottom: 10px; display: flex;
  flex-wrap: wrap; gap: var(--sp-3); padding: var(--sp-2) var(--sp-3); border-radius: var(--radius-sm);
  background: color-mix(in srgb, var(--bg) 80%, transparent); color: var(--text-dim);
  font-size: var(--text-5); }
.map-legend span { display: flex; align-items: center; gap: var(--sp-2); }
.legend-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--text-dim);
  flex: 0 0 auto; }
.legend-dot.k-external { border-radius: 2px; transform: rotate(45deg); }
.legend-dot.st-up { background: var(--ok); }
.legend-dot.st-down { background: var(--crit); }
.legend-dot.st-warn { background: var(--warn); }
.legend-dot.st-unknown { background: var(--unknown); }
.legend-dot.st-paused { background: var(--paused); }

/* Said plainly when it happens, because a blank map with no explanation is the
   one thing an offline homelab cannot distinguish from a broken dashboard. */
.map-note {
  position: absolute;
  top: 10px;
  left: 10px;
  max-width: min(420px, calc(100% - 80px));
  padding: var(--sp-2) var(--sp-3);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-sm);
  background: color-mix(in srgb, var(--surface) 94%, transparent);
  color: var(--text-dim);
  font-size: var(--text-4);
}

.map-unplaced { margin-top: var(--sp-3); padding: var(--sp-3) var(--sp-4); border: 1px dashed var(--border-strong);
  border-radius: var(--radius); color: var(--text-dim); font-size: var(--text-3); }

/* What is at one place. A marker stands for a site, and a site is usually more
   than one sensor, so this lists them rather than opening one at random. */
.map-pop {
  position: fixed;
  z-index: 60;
  min-width: 190px;
  max-width: 300px;
  padding: var(--sp-2);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  background: var(--surface-2);
  box-shadow: var(--shadow);
}
.map-pop-head { padding: var(--sp-1) var(--sp-3) var(--sp-2); font-weight: 650; border-bottom: 1px solid var(--border);
  margin-bottom: var(--sp-1); }
.map-pop-item {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  width: 100%;
  padding: var(--sp-2) var(--sp-3);
  border: 0;
  border-radius: var(--radius-sm);
  background: transparent;
  color: var(--text);
  font: inherit;
  font-size: var(--text-3);
  text-align: left;
  cursor: pointer;
}
.map-pop-item:hover { background: var(--surface-3); }
.map-pop-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

@media (max-width: 860px) {
  .tree-row { grid-template-columns: auto minmax(0, 1fr) auto; }
  .tree-row .row-spark, .tree-row .num { display: none; }
  .map-host { height: 60vh; }
}

/* -------------------------------------------------------------- sites list */

.sites-table td { vertical-align: middle; }
.site-kind-cell { display: flex; align-items: center; gap: var(--sp-2); }
.coord { font-family: var(--mono); font-size: var(--text-4); color: var(--text-dim); }

/* ------------------------------------------------------------ live updates */

/* One class, put on a node that was just rebuilt because something about it
   changed rather than merely aged, and taken off again a moment later. It moves
   nothing: the colour fades, the sparkline and the heartbeat fade in, and no
   element changes its size, because a card that grows on every probe would push
   the whole grid around once a minute.
   The global `prefers-reduced-motion` rule above already reduces both durations to
   nothing, which is the right answer here — the value still arrives, it arrives
   without the flourish. */
@keyframes flash-tint {
  from { background-color: color-mix(in srgb, var(--accent) 22%, transparent); }
  to { background-color: transparent; }
}

@keyframes flash-in {
  from { opacity: 0.25; }
  to { opacity: 1; }
}

.is-flash { animation: flash-tint 900ms ease-out; }
/* Whichever of the three shapes the row happens to be drawn in: a card's spark,
   the wall's heartbeat, the tree's sparkline. All three are the part of the row
   that shows what just happened, which is what fades in. */
.is-flash .card-spark, .is-flash .heartbeat, .is-flash .row-spark { animation: flash-in 500ms ease-out; }

/* A panel jumped to from a search result, so that the eye finds it after the
   page has switched category underneath it. */
.panel.is-flash { animation: flash-tint 1200ms ease-out; }

/* A sentence that has to be read before a button is pressed, rather than after. */
.warn-note {
  padding: var(--sp-2) var(--sp-3);
  border: 1px solid color-mix(in srgb, var(--warn) 40%, transparent);
  border-radius: var(--radius-sm);
  background: color-mix(in srgb, var(--warn) 12%, transparent);
  color: var(--text);
}

/* ------------------------------------------------------------------- icons */

/* A symbol, in the two forms it comes in.
 *
 * A drawing that ships with the program is served through a mask and painted in
 * the colour of the text it stands beside: one file then works in both themes and
 * under all six accents, and it reads as part of the dashboard rather than as a
 * picture pinned to it. An uploaded image is somebody's own picture with its own
 * colours, so it is drawn as it is — masking it would throw away the one thing it
 * was chosen for. Both boxes are the same size, which is what lets them sit in the
 * same slot in a row. */
.icon { flex: none; display: inline-block; vertical-align: -0.18em; border-radius: 4px; }
.icon-drawn {
  background-color: currentColor;
  -webkit-mask: var(--icon) center / contain no-repeat;
  mask: var(--icon) center / contain no-repeat;
}
.icon-image { object-fit: contain; }

/* Where a symbol leads a name: a card's title, a tree row, a list row. The gap is
   smaller than a word space would be, because the symbol belongs to the name. */
.icon-lead { margin-right: var(--sp-2); }

/* "No icon" is a choice and not a gap, so it is drawn as one: a dashed square in
   the same slot, at the same size, so that a row of choices does not jump. */
.icon-empty {
  border: 1px dashed var(--border-strong);
  border-radius: 5px;
  box-sizing: border-box;
}

/* The picker. It is a button carrying the current symbol and its name, and a panel
   that opens under it — not a select, because the choice is a picture and a native
   select cannot hold one. */
.icon-field { position: relative; display: inline-block; min-width: 210px; }
.icon-button {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  width: 100%;
  min-height: var(--ctl-h);
  padding: var(--sp-1) var(--sp-3);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface-2);
  color: var(--text);
  font: inherit;
  text-align: left;
  cursor: pointer;
}
.icon-button:hover { border-color: var(--border-strong); }
.icon-button-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.icon-caret { opacity: 0.6; }
.icon-slot { display: inline-flex; align-items: center; width: 18px; height: 18px; }

.icon-panel {
  position: absolute;
  z-index: 5;
  top: calc(100% + 6px);
  left: 0;
  width: min(520px, 92vw);
  max-height: 380px;
  overflow-y: auto;
  padding: var(--sp-3);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  background: var(--surface);
  box-shadow: var(--shadow);
}
.icon-panel-title {
  margin: var(--sp-3) var(--sp-0) var(--sp-2);
  color: var(--text-dim);
  font-size: var(--text-5);
  text-transform: uppercase;
  letter-spacing: 0.04em;
}
.icon-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(112px, 1fr)); gap: var(--sp-1); }
.icon-grid-uploads { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
/* The same empty box as everywhere else, with less air: it stands inside a panel
   that is already a box, in a corner of it. */
.icon-grid-uploads .empty { padding: var(--sp-4) var(--sp-3); justify-self: stretch; }

.icon-choice {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  padding: var(--sp-2) var(--sp-2);
  border: 1px solid transparent;
  border-radius: var(--radius-sm);
  background: transparent;
  color: var(--text);
  font: inherit;
  font-size: var(--text-4);
  line-height: 1.25;
  text-align: left;
  cursor: pointer;
}
/* A word that does not fit wraps onto a second line rather than being cut off: the
   grid is a list of words as much as a list of drawings, and "Certif…" is not the
   name of anything. */
.icon-choice span { min-width: 0; overflow-wrap: anywhere; }
.icon-choice:hover { background: var(--surface-2); border-color: var(--border); }
.icon-choice.is-current { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, transparent); }
.icon-choice-none { width: 100%; }
.icon-choice-wrap { position: relative; display: block; }
.icon-choice-wide { width: 100%; padding-right: var(--sp-5); }
/* The × sits inside the tile and only appears on hover or focus: a delete button
   beside every image is a row of crosses, and the thing being used here is the
   image. */
.icon-choice-x {
  position: absolute;
  top: 4px;
  right: 4px;
  width: 20px;
  height: 20px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: var(--surface-3);
  color: var(--text-dim);
  font-size: var(--text-3);
  line-height: 1;
  cursor: pointer;
  opacity: 0;
}
.icon-choice-wrap:hover .icon-choice-x,
.icon-choice-x:focus-visible { opacity: 1; }
.icon-choice-x:hover { color: var(--bad); }

.icon-panel-foot {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--sp-2);
  margin-top: var(--sp-3);
  padding-top: var(--sp-3);
  border-top: 1px solid var(--border);
}
.icon-upload { cursor: pointer; }
.icon-field.is-busy .icon-upload { opacity: 0.5; pointer-events: none; }

/* A group's heading wearing the colour somebody gave it.
 *
 * The colour is applied only where the attribute is there, so that a group with no
 * colour of its own keeps whatever the heading already looked like — a rule that
 * always set `color` would take that away from every uncoloured heading in the
 * dashboard, which is nearly all of them.
 *
 * The symbol beside the name inherits it too, because a built-in drawing is painted
 * in the colour of the text around it: a group's icon and its heading are one thing
 * visually, and they should be one colour. */
.group-title[data-colour],
.group-label[data-colour],
.wall-head[data-colour] { color: var(--group-colour); }
[data-colour='azure'] { --group-colour: var(--grp-azure); }
[data-colour='violet'] { --group-colour: var(--grp-violet); }
[data-colour='emerald'] { --group-colour: var(--grp-emerald); }
[data-colour='amber'] { --group-colour: var(--grp-amber); }
[data-colour='rose'] { --group-colour: var(--grp-rose); }
[data-colour='slate'] { --group-colour: var(--grp-slate); }

/* The settings panel's group list: the symbol, the name, the colour dot. */
.group-cell { display: inline-flex; align-items: center; gap: var(--sp-2); }
.group-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--group-colour, var(--border-strong)); }

/* -------------------------------------------------------------- responsive */

@media (max-width: 1024px) {
  /* Eight columns instead of twelve: the ring and everything with a list in it
     take half the row, a figure a quarter. The default header then falls into two
     rows — the ring and two figures, then two figures — which is what fits. */
  .widgets { grid-template-columns: repeat(8, minmax(0, 1fr)); }
}

@media (max-width: 860px) {
  main { padding: var(--sp-4); }
}

@media (max-width: 680px) {
  .cards { grid-template-columns: 1fr; }
  .wall { grid-template-columns: 1fr; }
  /* Four columns, and a tile is at least half the row: at a quarter of a phone
     screen a figure is one digit wide, and a figure nobody can read is not a
     figure. The ring and the lists take the whole row. */
  .widgets { grid-template-columns: repeat(4, minmax(0, 1fr)); }
  .widget-hero,
  .widget-wide { grid-column: span 4; }
}

