/* ---------------------------------------------------------------------------
   monitorstack dashboard

   Colour tokens come from the validated data-viz palette. The series slots are
   assigned in fixed order (blue, orange, aqua, yellow) rather than by what
   "looks solar" - the ordering is what makes the set colourblind-safe, so it is
   not rearranged for semantics. Dark values are separately stepped for the dark
   surface, not an automatic flip of the light ones.
--------------------------------------------------------------------------- */

:root {
  color-scheme: light;

  --plane:        #f9f9f7;
  --surface:      #fcfcfb;
  --surface-2:    #f2f1ed;
  --ink:          #0b0b0b;
  --ink-2:        #52514e;
  --ink-muted:    #898781;
  --grid:         #e1e0d9;
  --axis:         #c3c2b7;
  --border:       rgba(11, 11, 11, 0.10);

  --series-1:     #2a78d6;  /* PV       */
  --series-2:     #eb6834;  /* Load     */
  /* A PALER SERIES-2, for a slice that subdivides the battery rather than being
     its own source - T-306. Not a new hue on purpose: "battery, from grid" is
     part of the battery, and giving it series-3's green would read as grid. */
  --series-2b:    #f4a279;
  --series-3:     #1baf7a;  /* Grid     */
  --series-4:     #eda100;  /* Battery  */
  /* T-121's modelled curve. Deliberately NOT a fifth bright hue: it is drawn
     dashed and desaturated because it is a model beside four measurements, and
     a reader should be able to tell without consulting a legend. */
  --series-forecast: #7d6bb0;

  --good:         #0ca30c;
  --warning:      #fab219;
  --serious:      #ec835a;
  --critical:     #d03b3b;

  --radius:       10px;
  --shadow:       0 1px 2px rgba(11, 11, 11, 0.05), 0 4px 12px rgba(11, 11, 11, 0.04);
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    color-scheme: dark;
    --plane:     #0d0d0d;
    --surface:   #1a1a19;
    --surface-2: #232322;
    --ink:       #ffffff;
    --ink-2:     #c3c2b7;
    --ink-muted: #898781;
    --grid:      #2c2c2a;
    --axis:      #383835;
    --border:    rgba(255, 255, 255, 0.10);
    --series-1:  #3987e5;
    --series-2:  #d95926;
    --series-2b: #e8916a;
    --series-3:  #199e70;
    --series-4:  #c98500;
    --series-forecast: #9b8ac9;
    --shadow:    0 1px 2px rgba(0, 0, 0, 0.4), 0 4px 12px rgba(0, 0, 0, 0.3);
  }
}

:root[data-theme="dark"] {
  color-scheme: dark;
  --plane:     #0d0d0d;
  --surface:   #1a1a19;
  --surface-2: #232322;
  --ink:       #ffffff;
  --ink-2:     #c3c2b7;
  --ink-muted: #898781;
  --grid:      #2c2c2a;
  --axis:      #383835;
  --border:    rgba(255, 255, 255, 0.10);
  --series-1:  #3987e5;
  --series-2:  #d95926;
  --series-2b: #e8916a;
  --series-3:  #199e70;
  --series-4:  #c98500;
  --series-forecast: #9b8ac9;
  --shadow:    0 1px 2px rgba(0, 0, 0, 0.4), 0 4px 12px rgba(0, 0, 0, 0.3);
}

* { box-sizing: border-box; }

body {
  margin: 0;
  background: var(--plane);
  color: var(--ink);
  font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
  font-size: 15px;
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
}

h1, h2, h3 { margin: 0; font-weight: 600; letter-spacing: -0.01em; }

button {
  font: inherit;
  cursor: pointer;
  border-radius: 8px;
  border: 1px solid var(--border);
  background: var(--surface);
  color: var(--ink);
  padding: 8px 14px;
  transition: background 120ms ease, border-color 120ms ease;
}
button:hover { background: var(--surface-2); }
button:focus-visible { outline: 2px solid var(--series-1); outline-offset: 2px; }
button.primary {
  background: var(--series-1);
  border-color: var(--series-1);
  color: #fff;
  font-weight: 600;
}
button.primary:hover { filter: brightness(1.08); }
button[disabled] { opacity: 0.55; cursor: not-allowed; }

input {
  font: inherit;
  width: 100%;
  padding: 10px 12px;
  border-radius: 8px;
  border: 1px solid var(--border);
  background: var(--surface);
  color: var(--ink);
}
input:focus-visible { outline: 2px solid var(--series-1); outline-offset: 1px; border-color: var(--series-1); }

label { display: block; font-size: 13px; font-weight: 600; color: var(--ink-2); margin-bottom: 6px; }

/* --- password reveal ------------------------------------------------------
   The eye sits inside the field rather than beside it, so the form keeps one
   column and the control cannot drift away from the input it belongs to when
   the label wraps.

   Both icons are in the markup and CSS chooses between them on the button's
   own `aria-pressed`. That keeps the accessible state and the visible state
   the same fact rather than two that can disagree - a button that reads
   "Show password" while showing it is worse than no button. It also means no
   JavaScript writes a style attribute, which the CSP forbids outright
   (LESSONS 24).
--------------------------------------------------------------------------- */

/* A password box does not get wider than a password.
 *
 * `input { width: 100% }` is right for a card holding a table and wrong here:
 * the account page's card is the full width of the content column, so a
 * twelve-character box rendered about 1,100px wide on a desktop. It reads as a
 * mistake, and it puts the reveal button a hand's width from the text it
 * belongs to - on a wide screen the eye ends up nowhere near the eye.
 *
 * The whole form is capped rather than each input, so the hints and the
 * sign-out warning share the field's measure instead of running to twice it.
 * Capped, not fixed, so it still fills a narrow window.
 */
#password-form { max-width: 34rem; }

.pw-field { position: relative; display: block; }

/* Room for the button, so a long password does not run underneath it. */
.pw-field input { padding-right: 44px; }

.pw-reveal {
  position: absolute;
  top: 0;
  right: 0;
  height: 100%;
  width: 40px;
  display: grid;
  place-items: center;
  padding: 0;
  border: 0;
  border-radius: 0 8px 8px 0;
  background: transparent;
  color: var(--ink-muted);
  cursor: pointer;
}
.pw-reveal:hover { color: var(--ink); }
.pw-reveal:focus-visible { outline: 2px solid var(--series-1); outline-offset: -2px; }

.pw-reveal svg {
  width: 18px;
  height: 18px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.7;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* Hidden password: offer to show it. Revealed: offer to hide it. */
.pw-reveal .pw-eye-off { display: none; }
.pw-reveal[aria-pressed="true"] .pw-eye { display: none; }
.pw-reveal[aria-pressed="true"] .pw-eye-off { display: block; }
.pw-reveal[aria-pressed="true"] { color: var(--series-1); }

/* --- sign-in ------------------------------------------------------------- */

.auth-wrap {
  min-height: 100dvh;
  display: grid;
  place-items: center;
  padding: 24px;
}
.auth-card {
  width: 100%;
  max-width: 380px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
  padding: 32px;
}
.brand { display: flex; align-items: center; gap: 10px; margin-bottom: 4px; }
.brand-mark {
  width: 28px; height: 28px; border-radius: 7px; flex: none;
  background: linear-gradient(135deg, var(--series-4), var(--series-2));
}
.auth-sub { color: var(--ink-2); font-size: 14px; margin: 0 0 24px; }
.field { margin-bottom: 16px; }
.check { display: flex; align-items: center; gap: 8px; margin: 18px 0 20px; }
.check input { width: auto; margin: 0; accent-color: var(--series-1); }
.check label { margin: 0; font-weight: 500; color: var(--ink); }
.check small { display: block; color: var(--ink-muted); font-weight: 400; font-size: 12px; }

.alert {
  border-radius: 8px;
  padding: 10px 12px;
  font-size: 14px;
  margin-bottom: 16px;
  display: flex;
  gap: 8px;
  align-items: flex-start;
}
.alert-error   { background: color-mix(in srgb, var(--critical) 12%, transparent); color: var(--critical); }
.alert-warning { background: color-mix(in srgb, var(--warning) 16%, transparent); color: var(--ink); }
.alert[hidden] { display: none; }

/* --- app shell ----------------------------------------------------------- */

.topbar {
  display: flex;
  align-items: center;
  gap: 16px;
  padding: 12px 24px;
  background: var(--surface);
  border-bottom: 1px solid var(--border);
  position: sticky;
  top: 0;
  z-index: 20;
  flex-wrap: wrap;
}
.topbar .spacer { flex: 1; }
.who { text-align: right; line-height: 1.25; }
.who strong { font-size: 14px; }
.who span { display: block; font-size: 12px; color: var(--ink-muted); }

/* THE READING MEASURE, and why it is not 1400px any more.
 *
 * Rainer, 2026-08-21, for at least the third time: "go through the whole website
 * and check that every single link and site is 4k compatible."
 *
 * Measured before changing anything: fourteen of nineteen pages occupied 1400px
 * of a 3840px screen - 36% - because this cap applied to everything that had not
 * individually opted out with `.wide`. The five that had opted out were the five
 * somebody had been asked about before, one at a time, which is exactly the
 * pattern the audit document now exists to stop.
 *
 * The pages holding tables, charts and grids are now `.wide` and use the whole
 * screen. This cap is what is left: forms and settings, where a 3840px line of
 * text is genuinely worse to read than a 1400px one, and the original comment
 * below is right about that.
 *
 * But 1400 was a fixed pixel number, so it read as "half a screen" on a 4K
 * display and "the whole screen" on a laptop - the same design decision producing
 * two different results. `min(2200px, 92vw)` keeps a measure while scaling with
 * the display: 2200px at 4K, and on anything narrower than 2391px it is 92vw,
 * which is what a laptop was already getting. Nothing gets narrower than before.
 */
main { padding: 24px; max-width: min(2200px, 92vw); margin: 0 auto; }

/* The card grids, and only those.
   1400px is right for a page that is read - a settings form or a report at 3840
   wide would have lines nobody can track back. It is wrong for a wall of cards,
   which is what the hierarchy addendum reported: on a 4K display the fleet
   filled about half the screen and the grid looked fixed-width. It never was -
   `auto-fill minmax()` scales fine - the cap above was doing it.
   So the grid pages opt out and the reading pages keep their measure. No cap at
   all rather than a bigger one: §85 asks for the grid to scale to the display,
   and any number chosen here is somebody's taste about how wide is too wide,
   which is what the density control is for. */
main.wide { max-width: none; }

.section-head {
  display: flex;
  align-items: baseline;
  gap: 12px;
  margin: 32px 0 12px;
  flex-wrap: wrap;
}
.section-head:first-child { margin-top: 0; }
.section-head h2 { font-size: 16px; }
.section-head .hint { color: var(--ink-muted); font-size: 13px; }

/* --- stat tiles ---------------------------------------------------------- */

/* RESERVED FROM THE FIRST FRAME - T-120, and the row's rule: reserve, do not grow.
 *
 * These four tiles are rendered by JavaScript into an empty div, so the container
 * was 0px until the site payload landed and everything below it - the flow, the
 * Overview, the graph, the whole page - moved down by its height when it arrived.
 * Measured on the System page before this: `#eb-body` moved 632px and the graph
 * stage 698px, all inside the first 211ms.
 *
 * 120px is EXACT rather than approximate, and it is exact because of the rule
 * above: four tiles are always drawn (Solar now, Load, Battery, Grid - a fixed
 * list, not a data-dependent one), each is label + value + sub, and the sub now
 * always occupies its line. Measured at 1200, 1700 and 2560px on three Stations:
 * 120px everywhere once the sub is reserved.
 *
 * `min-height`, not `height`: at a narrow width the grid wraps to two rows and
 * must be allowed to. The reservation is the floor for the common case, not a cap.
 *
 *   MS_UI_WORKDIR=... ./tests/ui/run.sh site-detail    # asserts it holds */
.tiles {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(190px, 100%), 1fr));
  gap: 12px;
  min-height: 120px;
}
.tile {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 16px 18px;
  box-shadow: var(--shadow);
}
.tile .k {
  font-size: 12px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--ink-muted);
  display: flex;
  align-items: center;
  gap: 7px;
}
/* The swatch ties a tile to its series in the chart below, so identity is not
   carried by position alone. */
.swatch { width: 9px; height: 9px; border-radius: 2px; flex: none; }
.tile .v { font-size: 28px; font-weight: 600; margin-top: 6px; letter-spacing: -0.02em; }
.tile .v small { font-size: 15px; font-weight: 500; color: var(--ink-2); margin-left: 3px; }
/* THE SUB-LINE ALWAYS OCCUPIES ITS LINE, empty or not - T-120.
 *
 * "Solar now" carries "of 12 kW installed" and "Load" carries nothing, so a row
 * of four tiles was 120px tall on a Station with a capacity recorded and 102px on
 * one without. Reserving the line makes every tile the same height whatever the
 * data says, which is what lets `.tiles` below reserve an EXACT figure rather
 * than a guess that is 18px wrong on half the fleet.
 *
 * An empty line rather than hiding the element: `display: none` on an empty sub
 * would take the height back and reintroduce the difference. */
.tile .sub { font-size: 12px; color: var(--ink-muted); margin-top: 2px; min-height: 15px; }

/* THE TWO PANELS ABOVE THE GRAPH THAT CANNOT BE RESERVED EXACTLY - T-120.
 *
 * Both are rendered into an empty div after the payload lands, so both push
 * everything below them down when they arrive. Neither has a height that can be
 * known in advance, and the row is explicit that reserving a fixed height for a
 * variable-height panel trades movement for a blank gap:
 *
 *   #flow      197-406px   the diagram gains boxes for a battery and loses them
 *                          on a Station without one - a factor of two
 *   #ov-body   335-368px   eight figures, two of them conditional on a battery
 *                          and on revenue, in a grid that reflows by width
 *
 * MEASURED ACROSS ALL EIGHTEEN STATIONS, not sampled. The first pass used three
 * and got both numbers wrong in opposite directions: the flow floor was 209,
 * which is 12px ABOVE the smallest Station and would have opened a permanent gap
 * on it, and the Overview floor was 261 against a true minimum of 335, giving
 * away 74px of reservation for nothing. Three Stations is a sample; the fleet is
 * a measurement, and it is cheap.
 *
 * The heights are read as the sum of each container's CHILDREN, because a
 * container that reserves a floor reports the floor - measuring it directly would
 * measure the reservation rather than what it is reserving for.
 *
 * EACH RESERVES THE SMALLEST HEIGHT ANY STATION NEEDS AND NO MORE. That cannot
 * open a gap by construction. Reserving the TYPICAL height would remove more of
 * the movement and leave a visible hole on the smallest Stations - the trade the
 * row says to make per panel, and this is the conservative side of it, because a
 * gap is permanent and the residual movement is not.
 *
 * What is left is `#flow`, which genuinely varies two-fold with whether a Station
 * has a battery. Closing that needs the diagram's own shape in the markup rather
 * than a number here, which is a bigger change than this row.
 *
 * THESE ARE NOW THE COLD-VISIT ANSWER ONLY, and that is the whole of what a
 * stylesheet can say: one number has to be right for every Station at once, so it
 * has to be the smallest. On a SECOND visit `assets/reserve-boot.js` reserves what
 * THIS Station rendered last time - 336px rather than 197px for `#flow` on a
 * Station with a battery - because a number remembered per Station is under no
 * such obligation. Do not raise the figures here to close that gap: raising them
 * opens a permanent hole on the smallest Station, which is the trade this comment
 * exists to refuse. */
/* AND `#flow` PUBLISHES ITS RESERVATION AS A TOKEN AS WELL - T-120, Rainer's
 * answer (b) on 2026-09-10.
 *
 * `#flow` is the one reserved container a LAYOUT rule overrides:
 * `.site-cols-overview > .card > #flow` below sets `min-height: 0` so the flex
 * child can shrink and the two cards match (T-218, T-227), and at (1,2,0) it
 * beats this rule AND the adopted sheet's `#flow { min-height: … }` at (1,0,0).
 * So for ten days the remembered 308px was stored, applied, and computed to 0.
 * `#ov-body` is untouched by it - the only rules over that one are class-only,
 * (0,3,0), under an id - which is exactly why one worked and the other did not.
 *
 * The two rules now compose instead of competing: `reserve-boot.js` writes this
 * token beside its `min-height`, and the layout rule below CONSUMES it. The
 * default here is `0px` and not `197px` deliberately - a first visit, a private
 * window or a browser without constructable stylesheets then behaves exactly as
 * it did, and nothing about T-218's card matching changes. The reservation is
 * released the frame the content lands, so the token is 0px again from then on. */
#flow { --ms-reserve-h: 0px; min-height: 197px; }
#ov-body { min-height: 335px; }
/* The grouping page's own top panel, the same problem one tier up: 150-162px
   across all three Stations that exist, so 150 is the floor. `#figures` has no
   counterpart on a System page - that tier shows `.tiles` instead - which is why
   this is a third rule rather than a shared one. */
#figures { min-height: 150px; }

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

/* `minmax(min(300px, 100%), 1fr)`, not `minmax(300px, 1fr)`.
   
   A grid track with a fixed pixel floor cannot go below it, so on a 264px column
   - which is what a 320px phone leaves after the 56px nav rail and 16px of
   padding either side - a 300px card simply hangs out of the page and drags the
   whole document sideways with it. `min(300px, 100%)` says "300 when there is
   room, otherwise whatever there is".
   
   Applied to all twelve `repeat(auto-fit|auto-fill, minmax(Npx, ...))` tracks in
   this file rather than to the ones that happened to overflow first: they differ
   only in which viewport width exposes them. Desktop behaviour is unchanged,
   because above the floor `min()` returns the floor. */
.cards {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr));
  gap: 12px;
}
.card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 18px;
  box-shadow: var(--shadow);
}
.card-head { display: flex; align-items: flex-start; gap: 10px; margin-bottom: 14px; }
.card-head h3 { font-size: 15px; }
.card-head .meta { font-size: 12px; color: var(--ink-muted); }
.card-metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.card-metrics div { min-width: 0; }
.card-metrics .k { font-size: 11px; color: var(--ink-muted); text-transform: uppercase; letter-spacing: 0.04em; }
.card-metrics .v { font-size: 17px; font-weight: 600; white-space: nowrap; }

.pill {
  font-size: 11px;
  font-weight: 600;
  padding: 3px 8px;
  border-radius: 999px;
  white-space: nowrap;
  display: inline-flex;
  align-items: center;
  gap: 5px;
}
.pill-good     { background: color-mix(in srgb, var(--good) 15%, transparent);     color: var(--good); }
.pill-critical { background: color-mix(in srgb, var(--critical) 15%, transparent); color: var(--critical); }
.pill-warning  { background: color-mix(in srgb, var(--warning) 20%, transparent);  color: var(--ink); }
.pill-muted    { background: var(--surface-2); color: var(--ink-muted); }

/* A COUNT THAT WANTS THE WARNING TINT AND IS TOO LONG TO BE A PILL — T-462.
   ---------------------------------------------------------------------------
   `.pill` is `white-space: nowrap`, which is right for the state words above and
   wrong for a sentence. The status page's Stations column reached for
   `pill pill-warning` to colour "12 of 15 delivering · 1 stopped · 2 never
   reported", and at 320px that is **325px of unbreakable text in a 288px box** —
   the only thing on that page that scrolled sideways.

   Deliberately NOT a `.pill` variant and NOT a second `@media` block beside
   T-380's stacking rule: the phone rule already applied and worked (the cells
   were `display: block`), so a rule to "fix the narrow case" would have been a
   second rule for a case the first one already handles, which is how the toolbar
   acquired two. The defect was one control used for the wrong kind of content,
   so the repair is a control that fits the content. */
.count-warn {
  color: var(--ink);
  background: color-mix(in srgb, var(--warning) 20%, transparent);
  border-radius: 999px;
  padding: 2px 8px;
  /* The whole point: it wraps. */
  white-space: normal;
}
/* Modelled rather than measured data. Blue because it is not a fault or a
   warning - it is a statement about where the numbers came from - and it must
   not read as either. */
.pill-info     { background: color-mix(in srgb, var(--series-1) 15%, transparent); color: var(--series-1); }

/* --- chart --------------------------------------------------------------- */

.chart-card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 18px;
  box-shadow: var(--shadow);
  margin-bottom: 12px;
}
.chart-head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 4px; flex-wrap: wrap; }
.chart-head h3 { font-size: 15px; }
.chart-head .unit { font-size: 12px; color: var(--ink-muted); }
.chart-head .spacer { flex: 1; }

.legend { display: flex; gap: 16px; flex-wrap: wrap; margin: 10px 0 6px; }
.legend-item {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  font-size: 13px;
  color: var(--ink-2);
  cursor: pointer;
  user-select: none;
  background: none;
  border: none;
  padding: 2px 0;
}
.legend-item[aria-pressed="false"] { opacity: 0.4; }
.legend-item .swatch { width: 11px; height: 11px; border-radius: 3px; }

.chart-holder { position: relative; }
canvas { display: block; width: 100%; touch-action: pan-y; }

.tooltip {
  position: absolute;
  pointer-events: none;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 8px;
  box-shadow: var(--shadow);
  padding: 9px 11px;
  font-size: 12px;
  min-width: 150px;
  z-index: 10;
  transition: opacity 90ms ease;
}
.tooltip[hidden] { display: none; }
.tooltip .t-time { font-weight: 600; margin-bottom: 6px; color: var(--ink); }
.tooltip .t-row { display: flex; align-items: center; gap: 7px; margin-top: 3px; color: var(--ink-2); }
.tooltip .t-row .spacer { flex: 1; }
.tooltip .t-row b { color: var(--ink); font-variant-numeric: tabular-nums; font-weight: 600; }

/* --- table view (the relief for sub-3:1 series on the light surface) ------ */

.table-wrap { overflow-x: auto; margin-top: 12px; }
table { border-collapse: collapse; width: 100%; font-size: 13px; }
th, td { text-align: right; padding: 6px 10px; border-bottom: 1px solid var(--grid); white-space: nowrap; }
th:first-child, td:first-child { text-align: left; }
th { color: var(--ink-muted); font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; }

/* THE STATUS PAGE'S TABLES — T-344, and Rainer 2026-09-04: "all field on status
   needs to be left orientated its right now" and "on smaller screens its needs
   wrap around instead of the scroll bar".
   ---------------------------------------------------------------------------
   SCOPED TO THIS PAGE RATHER THAN FIXING THE RULE ABOVE, and that is the whole
   decision. `th, td { text-align: right }` with a left first column is right for
   the twenty-odd pages of figures it was written for - a column of kWh reads
   down its decimal point. This page has no figures in it. Every cell is a word,
   a sentence or a timestamp, and right-aligning prose puts the ragged edge on
   the side the eye starts from.

   `white-space: nowrap` is the other half. On a table of numbers it stops a
   figure breaking across lines; here it makes a full-sentence reason one
   unbreakable line, which is what produces the horizontal scrollbar Rainer is
   looking at. Wrapping is what a sentence is supposed to do.

   Both are overridden HERE and nowhere else: changing lines 529-530 would
   re-align every table in the product on the strength of one page's needs. */
.status-table th,
.status-table td {
  text-align: left;
  white-space: normal;
  /* Rows are of very unequal height once the reasons wrap, so the state pill
     and the label must sit at the top of their row rather than floating in the
     middle of a three-line cell beside them. */
  vertical-align: top;
}

/* The state pill is one word and must not break across two lines - it is the
   thing being scanned for, and a wrapped "UNKNOWN" reads as two states. */
.status-table td:first-child { white-space: nowrap; }

/* Keep the two prose columns from collapsing to a few characters wide on a
   phone while the short ones take the room. Percentages rather than pixels, so
   the table reflows instead of demanding a minimum width - a min-width here
   would put the scrollbar straight back. */
.status-table th:nth-child(2), .status-table td:nth-child(2) { width: 34%; }
.status-table th:nth-child(5), .status-table td:nth-child(5) { width: 26%; }

/* AND ON A PHONE THE ROW STACKS, because wrapping the text is not enough.
   ---------------------------------------------------------------------------
   Measured rather than assumed, by `tests/ui/status-page.mjs`: with the text
   wrapping and nothing else, five columns still could not fit below **499px of
   content in a 288px box** at 320px wide. A table has a minimum width its
   columns cannot go under - the longest unbreakable word in each, plus five
   lots of padding - and no amount of `white-space: normal` reaches it.

   `.table-wrap` is `overflow-x: auto`, so that overflow scrolls INSIDE the box
   and the document never moves. `mobile-overflow.mjs` was green throughout,
   because it measures the page. **The scrollbar Rainer is looking at is this
   one**, and the only honest fix is to stop being a table at this width.

   So under 560px each row becomes a block, the header row is dropped, and each
   cell prints its own heading from `data-label` - which `status.js` writes,
   because the label and the column heading are the same words and two copies of
   a heading drift. */
@media (max-width: 560px) {
  .status-table thead { display: none; }

  .status-table, .status-table tbody, .status-table tr, .status-table td { display: block; width: auto; }

  .status-table tr {
    padding: 10px 0;
    border-bottom: 1px solid var(--grid);
  }

  .status-table td {
    /* The percentage widths above are for columns and there are none now. */
    width: auto !important;
    border-bottom: 0;
    padding: 2px 0;
    white-space: normal;
  }

  /* An empty cell would otherwise print a heading with nothing after it - the
     `Why` column is empty on every healthy check. */
  .status-table td:empty { display: none; }

  .status-table td::before {
    content: attr(data-label);
    display: block;
    color: var(--ink-muted);
    font-size: 11px;
    text-transform: uppercase;
    letter-spacing: 0.04em;
  }

  /* The state pill is the thing being scanned for; it keeps its own line and
     needs no heading, because the pill IS the heading. */
  .status-table td:first-child::before { content: none; }
}

/* A manufacturer heading inside a table body — the connections page (T-169).
   NOT a column header, so none of the above applies: it is a sentence, and the
   uppercase treatment above turned it into three lines of shouting where the
   per-row version it replaced had been sixteen lines of noise. Trading one for
   the other would have been no gain, and the screenshot is what showed it. */
tr.group-row th {
  text-transform: none;
  letter-spacing: 0;
  font-size: 12px;
  text-align: left;
  padding-top: 14px;
  background: var(--surface-2, transparent);
  border-top: 1px solid var(--border);
}
td { font-variant-numeric: tabular-nums; }
tbody tr:hover td { background: var(--surface-2); }

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

/* A toolbar wraps, and its items may shrink.
   
   `flex-wrap` alone was not enough: it moves items to the next line, but a
   single item wider than the row still overflows, and a `<select>` sizes itself
   to its widest option. On the dashboard the site picker was 575px, so at 390px
   the page scrolled 257px sideways with the toolbar looking perfectly ordinary. */
.controls { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; max-width: 100%; }
.controls > * { min-width: 0; max-width: 100%; }
select { max-width: 100%; }
select {
  font: inherit;
  padding: 7px 10px;
  border-radius: 8px;
  border: 1px solid var(--border);
  background: var(--surface);
  color: var(--ink);
}
.seg { display: inline-flex; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
.seg button { border: none; border-radius: 0; padding: 7px 12px; font-size: 13px; background: var(--surface); }
.seg button + button { border-left: 1px solid var(--border); }
.seg button[aria-pressed="true"] { background: var(--series-1); color: #fff; font-weight: 600; }

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

@media (max-width: 640px) {
  main, .topbar { padding: 16px; }
  .tile .v { font-size: 24px; }
}

/* --- navigation tabs ----------------------------------------------------- */

/* --- the sidebar ----------------------------------------------------------
   The navigation moved from a row across the top to a column down the left,
   because a row runs out of width. At thirteen entries it already wraps on a
   laptop; at the scale this platform is aimed at there will be more, and a
   wrapping row pushes the page content down by a line nobody asked for.
   A column scrolls instead, which is a thing people expect a column to do.

   Positioned rather than restructured: the markup still has the nav inside the
   top bar on every page, so this is one stylesheet rather than thirteen page
   edits. `nav.js` marks the document so pages without a nav - the sign-in
   screen and the wall display - keep their full width.
-------------------------------------------------------------------------- */

:root {
  --nav-width: 208px;
  --nav-rail: 56px;
  --topbar-height: 57px;
}

.tabs {
  display: flex;
  flex-direction: column;
  gap: 2px;
  position: fixed;
  top: var(--topbar-height);
  left: 0;
  bottom: 0;
  width: var(--nav-width);
  padding: 10px 8px;
  background: var(--surface);
  border-right: 1px solid var(--border);
  /* The point of the change: too many entries scroll rather than reflow. */
  overflow-y: auto;
  overscroll-behavior: contain;
  z-index: 15;
  transition: width 140ms ease;
}

.tabs a {
  display: flex;
  align-items: center;
  gap: 10px;
  color: var(--ink-2);
  text-decoration: none;
  font-size: 14px;
  font-weight: 500;
  padding: 8px 10px;
  border-radius: 8px;
  white-space: nowrap;
  /* A long label in a narrow column must not push the icon out of line. */
  overflow: hidden;
  /* And an entry must never be squashed to make the list fit.
     A flex item in a column shrinks by default, so without this the links
     compress into an unreadable stack instead of the column scrolling -
     which is the whole reason the navigation moved to the side. */
  flex: none;
}
.tabs a svg {
  width: 16px;
  height: 16px;
  flex: none;
  fill: currentColor;
  opacity: 0.75;
}
/* --- grouped navigation — T-346 -------------------------------------------
   Rainer, 2026-09-03: "the items needs to be grouped and in dropdowns in the
   right category", and on 2026-09-04 he chose the cut: Monitor, Report, Fleet,
   Commercial, System — with Dashboard pinned above them and Your account below.

   The heading is a button rather than a div, so it is reachable by keyboard and
   announces its own state through `aria-expanded`. It is styled to read as a
   heading and not as another destination: no icon-plus-label symmetry with the
   entries under it would tell you which of the two you can click TO. */
.nav-group { display: flex; flex-direction: column; flex: none; }

.nav-group-head {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  padding: 10px 10px 6px;
  background: none;
  border: 0;
  cursor: pointer;
  color: var(--ink-muted);
  font: inherit;
  font-size: 11px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  white-space: nowrap;
  overflow: hidden;
}
.nav-group-head:hover { color: var(--ink); }
.nav-group-head svg { width: 14px; height: 14px; flex: none; fill: currentColor; opacity: 0.7; }
.nav-group-head .nav-caret { margin-left: auto; transition: transform 120ms ease; }
.nav-group.is-closed .nav-caret { transform: rotate(-90deg); }

.nav-group-items { display: flex; flex-direction: column; gap: 2px; }
.nav-group.is-closed .nav-group-items { display: none; }

/* THE COLLAPSED RAIL, and this is T-346's done-when 2.
   ---------------------------------------------------------------------------
   At 56px there are no labels, so a group heading is one more anonymous icon
   and a folded group is four icons that have silently disappeared. A flyout
   anchored to a rail icon is the usual answer, and it is a great deal of
   machinery - positioning, z-index, hover intent, touch - for the mode whose
   entire purpose is to be out of the way.

   So the rail drops the headings and shows every entry, separated by a rule.
   A 1px line is visible at 56px where a text heading is not, which is exactly
   what "a separator that survives the collapsed rail" has to mean. `nav.js`
   keeps a fold from hiding anything while the rail is collapsed. */
[data-nav-collapsed] .nav-group-head { display: none; }
[data-nav-collapsed] .nav-group-items { display: flex; }
[data-nav-collapsed] .nav-group + .nav-group,
[data-nav-collapsed] .nav-group {
  border-top: 1px solid var(--border);
  margin-top: 6px;
  padding-top: 6px;
}

/* AN ENTRY WHOSE PERMISSION IS NOT YET KNOWN — T-354.
   Shown, so the sidebar has its final shape on the first frame and does not
   rearrange when /v1/auth/me answers. Not followable, because an entry that
   can be clicked before we know whether it will 403 is the invites-and-denies
   failure `_nav.html`'s comment says this product has been caught by three
   times. It lasts one round trip and only on the first page load after a
   sign-in - `login` and `refresh` now return the permissions, so every load
   after that has them before the first mount. */
.tabs a.is-pending { pointer-events: none; opacity: 0.45; }

.tabs a:hover { background: var(--surface-2); color: var(--ink); }
.tabs a.active { background: var(--surface-2); color: var(--ink); font-weight: 600; }
.tabs a.active svg { opacity: 1; }
.tabs a[hidden] { display: none; }

/* Your account sits at the foot of the column, not in the run of screens:
   it is about the person rather than the fleet, and it is reached rarely.

   `margin-top: auto` pins it when there is room and quietly does nothing when
   the column has overflowed - at which point it is simply the last entry in
   the scroll, which is where it belongs anyway. Only one item may claim the
   free space, so the collapse control below it takes none. */
.tabs a[data-page="account"] {
  margin-top: auto;
  position: relative;
}
.tabs a[data-page="account"]::before {
  content: '';
  position: absolute;
  left: 8px;
  right: 8px;
  top: -5px;
  border-top: 1px solid var(--border);
}

/* The collapse control, appended by nav.js so no page has to carry it. */
.nav-collapse {
  /* Below the account entry, which is what claims the space above it. */
  margin-top: 0;
  flex: none;
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 8px 10px;
  border: 0;
  background: none;
  color: var(--ink-muted);
  font: inherit;
  font-size: 13px;
  border-radius: 8px;
  cursor: pointer;
  white-space: nowrap;
  overflow: hidden;
}
.nav-collapse:hover { background: var(--surface-2); color: var(--ink); }
.nav-collapse svg { width: 16px; height: 16px; flex: none; fill: currentColor; transition: transform 140ms ease; }

/* Content sits beside the sidebar, but only on pages that have one. */
[data-nav="side"] main { margin-left: var(--nav-width); transition: margin-left 140ms ease; }

/* --- collapsed ---
   A rail of icons. The labels are hidden rather than removed so the title
   attribute still names each one on hover, and a screen reader still reads
   the link text - an icon-only nav that is silent to a screen reader is not a
   nav, it is decoration. */
[data-nav-collapsed] .tabs { width: var(--nav-rail); }
[data-nav-collapsed] .tabs a { justify-content: center; padding: 8px; }
[data-nav-collapsed] .tabs a span,
[data-nav-collapsed] .nav-collapse span {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
}
[data-nav-collapsed] .nav-collapse { justify-content: center; padding: 8px; }
[data-nav-collapsed] .nav-collapse svg { transform: rotate(180deg); }
[data-nav="side"][data-nav-collapsed] main { margin-left: var(--nav-rail); }

/* --- narrow screens ---
   Below this a fixed column costs more than it gives, so it becomes the rail
   whatever the stored preference says. The preference is not overwritten:
   widening the window restores it. */
@media (max-width: 900px) {
  [data-nav="side"] .tabs { width: var(--nav-rail); }
  [data-nav="side"] .tabs a { justify-content: center; padding: 8px; }
  [data-nav="side"] .tabs a span, [data-nav="side"] .nav-collapse span {
    position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%);
  }
  [data-nav="side"] .nav-collapse { justify-content: center; padding: 8px; }
  [data-nav="side"] main { margin-left: var(--nav-rail); }
}

/* ORDER MATTERS AND THIS BLOCK MUST STAY BELOW THE 900px ONE ABOVE. A media
   query adds no specificity, so at 390px both match and the later wins - with
   this placed first, the rail's `margin-left` and width simply overrode the
   drawer and the toggle never appeared, while the CSS read correctly in
   isolation. The same mistake the flow diagram's mobile block already carries a
   note about; twice in one afternoon is a property of the stylesheet's shape
   rather than of anybody's care.
   -------------------------------------------------------------------------- */
/* The button that opens it, and the scrim behind it.
   
   DECLARED ABOVE THE MEDIA BLOCK, not below it. `display: none` here and
   `display: inline-flex` in the phone block are the same specificity, so
   whichever comes last wins - and written the other way round the base rule
   overrode the phone rule and the toggle never appeared at any width. Third
   time this stylesheet's flat specificity has cost an hour today, and the first
   two were not mine. */
.nav-toggle {
  display: none;
  align-items: center;
  justify-content: center;
  width: 34px;
  height: 34px;
  margin-right: 2px;
  padding: 0;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--surface);
  color: var(--ink);
  cursor: pointer;
}
.nav-toggle svg { width: 17px; height: 17px; fill: currentColor; display: block; }
.nav-toggle:hover { border-color: var(--ink-muted); }

/* The scrim. It is what closes the drawer on a tap outside, and it is a real
   element rather than a `::before` so it can carry that listener and be hidden
   from assistive technology in one place. */
.nav-scrim {
  position: fixed;
  inset: 0;
  z-index: 39;
  background: rgba(0, 0, 0, 0.42);
  opacity: 0;
  visibility: hidden;
  transition: opacity 180ms ease, visibility 0s linear 180ms;
}
:root[data-nav-open] .nav-scrim {
  opacity: 1;
  visibility: visible;
  transition: opacity 180ms ease, visibility 0s;
}

/* While the drawer is open the page behind it does not scroll. Without this a
   thumb dragging on the scrim scrolls the content underneath, which reads as
   the menu having lost the page. */
:root[data-nav-open] body { overflow: hidden; }


/* --------------------------------------------------------------------------
   MOBILE: the sidebar is a drawer, not a rail.

   Below 640px the rail was taking 56px of a 320px screen - 17% of everything a
   person can see, permanently, to show icons they are not using. So it goes
   off-canvas and a button in the top bar brings it back over the content.

   Off-canvas by TRANSFORM rather than by `display: none`, so it slides, and so
   the links stay in the document for a screen reader to reach through the
   button's `aria-controls`. `visibility` is what actually takes it out of the
   tab order while closed - a transformed element is still focusable, and a
   keyboard user tabbing into an invisible menu is worse than no menu.
-------------------------------------------------------------------------- */
@media (max-width: 640px) {
  [data-nav="side"] .tabs {
    top: 0;
    bottom: 0;
    width: min(var(--nav-width), 82vw);
    z-index: 40;
    box-shadow: 0 0 0 100vmax rgba(0, 0, 0, 0), 12px 0 28px rgba(0, 0, 0, 0.28);
    transform: translateX(-101%);
    visibility: hidden;
    transition: transform 180ms ease, visibility 0s linear 180ms;
    padding-top: 14px;
  }

  /* Both attributes are on the SAME element - `<html>` carries `data-nav="side"`
     and `data-nav-open` - so they are written unspaced. With a descendant
     combinator this asks for an `<html>` inside an `<html>`, matches nothing,
     and the drawer stays parked while every other part of opening it works:
     the scrim appears, the page locks, aria-expanded flips to true. */
  :root[data-nav-open][data-nav="side"] .tabs {
    transform: none;
    visibility: visible;
    transition: transform 180ms ease, visibility 0s;
  }

  /* The content takes the whole width back. */
  [data-nav="side"] main { margin-left: 0; }

  /* The collapse control is a desktop idea - rail or full - and means nothing
     for a drawer that is either open or gone. */
  .nav-collapse { display: none; }

  /* Full labels inside the drawer even though the 900px rule hides them for the
     rail: there is room for words here, and an icon-only drawer would be a rail
     that costs a tap. */
  [data-nav="side"] .tabs a { justify-content: flex-start; padding: 10px 12px; }
  [data-nav="side"] .tabs a span {
    position: static;
    width: auto;
    height: auto;
    overflow: visible;
    clip-path: none;
  }

  .nav-toggle { display: inline-flex; }
}

@media (prefers-reduced-motion: reduce) {
  [data-nav="side"] .tabs, .nav-scrim { transition: none; }
}


@media print {
  .tabs, .topbar { display: none; }
  [data-nav="side"] main { margin-left: 0; }
}

/* --- drawer (user editor) ------------------------------------------------ */

.drawer-backdrop {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.4);
  z-index: 40;
}
.drawer-backdrop[hidden] { display: none; }

.drawer {
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  width: min(560px, 100vw);
  background: var(--surface);
  border-left: 1px solid var(--border);
  z-index: 50;
  display: flex;
  flex-direction: column;
  box-shadow: -8px 0 32px rgba(0, 0, 0, 0.12);
}
.drawer[hidden] { display: none; }
.drawer-head {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 16px 20px;
  border-bottom: 1px solid var(--border);
  flex: none;
}
.drawer-head h2 { font-size: 16px; flex: 1; }
.drawer-body { padding: 20px; overflow-y: auto; flex: 1; }

.drawer-section { margin-bottom: 26px; }
.drawer-section > h3 {
  font-size: 12px;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--ink-muted);
  margin-bottom: 10px;
}
.drawer-actions {
  display: flex;
  gap: 8px;
  flex-wrap: wrap;
  padding-top: 16px;
  border-top: 1px solid var(--border);
}

/* --- permission toggles -------------------------------------------------- */

.perm-group { margin-bottom: 16px; }
.perm-group > h4 {
  font-size: 13px;
  font-weight: 600;
  margin: 0 0 6px;
  color: var(--ink);
}
.perm {
  display: flex;
  align-items: flex-start;
  gap: 10px;
  padding: 7px 10px;
  border-radius: 8px;
  cursor: pointer;
}
.perm:hover { background: var(--surface-2); }
.perm input { width: auto; margin: 2px 0 0; accent-color: var(--series-1); flex: none; }
.perm span { font-size: 13px; color: var(--ink-2); line-height: 1.35; }
/* A LONG UNBROKEN TOKEN INSIDE `code` MUST NOT PUSH THE DOCUMENT — T-386.
   ---------------------------------------------------------------------------
   `chart-catalogue.html` quotes URLs and file paths inside inline `code`:
   `learn.microsoft.com/power-bi/visuals/power-bi-map-visualizations-overview`
   is one token, so nothing in it is a break opportunity and the element sizes to
   its content. Measured 2026-09-05: a 446px `code` in a 264px column, dragging
   the whole document sideways by 160px at 320, 120 at 360, 90 at 390 and 50 at
   430 — the single failure `mobile-overflow.mjs` exists to catch, on a page that
   harness had never visited until T-383 derived its list.

   `break-word` RATHER THAN `anywhere`, and I tried it the other way round first
   because `anywhere` sounds stronger. It is, and that is the problem: `anywhere`
   also shrinks the element's MIN-CONTENT width, so a parent that sizes to its
   content collapses onto it and short tokens start breaking that never needed
   to. Measured at 390px with `anywhere`: `dashboards.view` — fifteen characters,
   52px — broke across THREE LINES on `dashboards.html`. The overflow was gone
   and the page was worse, which is T-380's trade exactly (a page that no longer
   scrolls while rendering an A4 sheet twelve pixels wide).
   `break-word` breaks a token only when it would otherwise overflow its line,
   and leaves intrinsic sizing alone.

   GLOBAL, and that is the decision rather than a convenience. `code` appears on
   five pages; the other four hold short tokens (90-126px) and are unaffected,
   measured before and after. A rule that reached this page alone would be the
   fix-one-of-a-pair mistake this codebase makes most often (LESSONS 116), and
   there is no page where a URL silently widening the document is wanted.

   `pre` is deliberately NOT included. A preformatted block wants a scroll
   container of its own (T-344's wrapper-not-document rule), not mid-token
   breaks that would destroy the alignment it exists to preserve. No `pre`
   overflows today; the day one does, it wants `.table-wrap`'s treatment. */
code { overflow-wrap: break-word; }

.perm code {
  display: block;
  font-size: 11px;
  color: var(--ink-muted);
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}

.danger {
  background: color-mix(in srgb, var(--critical) 12%, transparent);
  border-color: color-mix(in srgb, var(--critical) 35%, transparent);
  color: var(--critical);
  font-weight: 600;
}
.danger:hover { background: color-mix(in srgb, var(--critical) 20%, transparent); }

.credential {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 15px;
  background: var(--surface-2);
  border: 1px dashed var(--border);
  border-radius: 8px;
  padding: 12px;
  margin: 8px 0;
  user-select: all;
  word-break: break-all;
}

.ip-row {
  display: flex;
  gap: 10px;
  align-items: baseline;
  padding: 6px 0;
  border-bottom: 1px solid var(--grid);
  font-size: 13px;
}
.ip-row code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
.ip-row .spacer { flex: 1; }
.ip-row .when { color: var(--ink-muted); font-size: 12px; }

/* --- boards: the 12-column card grid -------------------------------------- */

.grid {
  display: grid;
  grid-template-columns: repeat(var(--grid-columns, 12), 1fr);
  /* 76px matches ROW_HEIGHT in grid.js, which the editor's drag maths needs.
     A wall display overrides --grid-row so the whole board fits its screen:
     nobody can scroll a TV, so anything below the fold is invisible and the
     screen would look complete while hiding half its cards. */
  grid-auto-rows: var(--grid-row, 76px);
  gap: 12px;
  align-content: start;
}

.grid-card {
  position: relative;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
  overflow: hidden;
  min-width: 0;                  /* lets a card shrink instead of forcing
                                    the grid wider than the page */
}
.grid-card-body {
  height: 100%;
  padding: 14px 16px;
  display: flex;
  flex-direction: column;
  min-height: 0;
  min-width: 0;
}

/* Edit mode. The affordances only exist while editing, so a wall display has
   no controls on it at all - nothing for a passer-by to press. */
.grid-editing .grid-card {
  cursor: grab;
  border-style: dashed;
  border-color: color-mix(in srgb, var(--series-1) 45%, var(--border));
}
.grid-editing .grid-card:hover { border-color: var(--series-1); }
.grid-card-dragging {
  cursor: grabbing;
  opacity: 0.85;
  z-index: 5;
  box-shadow: 0 8px 24px rgba(11, 11, 11, 0.18);
}

.grid-chrome {
  position: absolute;
  top: 6px;
  right: 6px;
  display: flex;
  gap: 4px;
  z-index: 2;
}
.grid-btn {
  width: 26px;
  height: 26px;
  padding: 0;
  font-size: 13px;
  line-height: 1;
  border-radius: 6px;
  background: var(--surface-2);
}
/* Bottom-right resize grip. 22px is about the smallest reliable touch target
   that does not dominate a small card. */
.grid-resize {
  position: absolute;
  right: 0;
  bottom: 0;
  width: 22px;
  height: 22px;
  cursor: nwse-resize;
  background: linear-gradient(135deg, transparent 50%, var(--axis) 50%, var(--axis) 62%, transparent 62%);
  touch-action: none;
}
.grid-editing .grid-card { touch-action: none; }   /* stop the page panning mid-drag */

/* --- card internals ------------------------------------------------------- */

.card-title {
  font-size: 12px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--ink-muted);
  margin-bottom: 8px;
  flex: none;
}
.card-body { flex: 1; min-height: 0; overflow: auto; }
.card-meta { font-size: 12px; color: var(--ink-muted); margin-top: 8px; flex: none; }
.card-chart { flex: 1; min-height: 0; }
.card-legend { display: flex; gap: 14px; flex-wrap: wrap; font-size: 12px; color: var(--ink-2); margin-top: 6px; flex: none; }
.card-legend .legend-item { display: flex; align-items: center; gap: 6px; }
.card-heading { font-size: 20px; font-weight: 600; letter-spacing: -0.01em; }
.card-unavailable {
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  text-align: center;
  font-size: 13px;
  color: var(--ink-muted);
  padding: 8px;
}

/* KPI cards reuse the dashboard tile's type scale so the two pages do not
   disagree about how big a number is. */
.grid-card .k {
  font-size: 12px; font-weight: 600; text-transform: uppercase;
  letter-spacing: 0.04em; color: var(--ink-muted);
  display: flex; align-items: center; gap: 7px;
}
.grid-card .v { font-size: 28px; font-weight: 600; margin-top: 6px; letter-spacing: -0.02em; }
.grid-card .v small { font-size: 15px; font-weight: 500; color: var(--ink-2); margin-left: 3px; }
.grid-card .sub { font-size: 12px; color: var(--ink-muted); margin-top: 2px; }

/* --- site tiles ----------------------------------------------------------- */

.site-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(min(230px, 100%), 1fr));
  gap: 10px;
}
.site-tile {
  border: 1px solid var(--border);
  border-left-width: 4px;
  border-radius: 8px;
  padding: 10px 12px;
  background: var(--surface-2);
}
/* State is carried by a word and a shape as well as colour: a wall display is
   read at a distance, sometimes by a colourblind viewer, often on a TV whose
   colour is badly calibrated. */
.site-ok       { border-left-color: var(--good); }
.site-warn     { border-left-color: var(--warning); }
.site-critical { border-left-color: var(--critical); }
.site-unknown  { border-left-color: var(--ink-muted); }

.site-tile-head { display: flex; align-items: baseline; gap: 7px; margin-top: 3px; }
.site-mark { font-size: 13px; flex: none; }
.site-ok .site-mark       { color: var(--good); }
.site-warn .site-mark     { color: var(--warning); }
.site-critical .site-mark { color: var(--critical); }
.site-unknown .site-mark  { color: var(--ink-muted); }

/* The name owns its own line and is sized to be read across a room. */
.site-name {
  font-weight: 600;
  font-size: 16px;
  line-height: 1.2;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.site-word { font-size: 11px; font-weight: 700; letter-spacing: 0.06em; flex: none; }
.site-ok .site-word       { color: var(--good); }
.site-warn .site-word     { color: var(--warning); }
.site-critical .site-word { color: var(--critical); }
.site-unknown .site-word  { color: var(--ink-muted); }
.site-tile-metrics {
  display: flex; justify-content: space-between; gap: 8px;
  margin-top: 8px; font-size: 12px; color: var(--ink-muted);
}

.card-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.card-table td { padding: 6px 8px; border-bottom: 1px solid var(--border); }
.card-table tr:last-child td { border-bottom: none; }
.cell-good     { color: var(--good); }
.cell-critical { color: var(--critical); font-weight: 600; }

/* --- dialog --------------------------------------------------------------- */

.dialog {
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface);
  color: var(--ink);
  padding: 0;
  min-width: min(420px, 92vw);
  box-shadow: 0 12px 40px rgba(11, 11, 11, 0.22);
}
.dialog::backdrop { background: rgba(11, 11, 11, 0.42); }
.dialog-form { padding: 20px; display: flex; flex-direction: column; gap: 14px; }
.dialog-form h3 { margin: 0; font-size: 16px; }
.dialog-form label { display: flex; flex-direction: column; gap: 5px; font-size: 13px; color: var(--ink-2); }
.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; }
.btn-quiet { background: transparent; }

/* Narrow screens: a 12-column grid is unreadable on a phone, so every card
   becomes full width and the drag editor is effectively view-only there. */
@media (max-width: 700px) {
  .grid { grid-template-columns: 1fr; }
  .grid-card { grid-column: 1 / -1 !important; }
}

/* --- CSP-safe utilities ---------------------------------------------------
   The service sends `style-src 'self'` with no 'unsafe-inline', so a style=""
   attribute is silently discarded by the browser. Every swatch on the site was
   rendering transparent because of it - which quietly defeated the rule that a
   series must be identifiable by more than position.

   These classes and data attributes replace the inline styles. Adding
   'unsafe-inline' to the policy would have been the one-line fix and the wrong
   one: it re-enables exactly the injection class the policy is there to stop,
   for the sake of a dozen colour declarations that belong in a stylesheet.
-------------------------------------------------------------------------- */

.swatch[data-c="series-1"] { background: var(--series-1); }
.swatch[data-c="series-2"] { background: var(--series-2); }
.swatch[data-c="series-3"] { background: var(--series-3); }
.swatch[data-c="series-4"] { background: var(--series-4); }
.swatch[data-c="good"]     { background: var(--good); }
.swatch[data-c="warning"]  { background: var(--warning); }
.swatch[data-c="serious"]  { background: var(--serious); }
.swatch[data-c="critical"] { background: var(--critical); }
.swatch[data-c="unknown"]  { background: var(--ink-muted); }

.grow        { flex: 1; }
.grow-min0   { flex: 1; min-width: 0; }
.w-full      { width: 100%; }
.mt-10       { margin-top: 10px; }
.mb-10       { margin-bottom: 10px; }
.muted-sm    { font-size: 12px; color: var(--ink-muted); }
.muted-13    { font-size: 13px; color: var(--ink-muted); }
.muted-11    { font-size: 11px; color: var(--ink-muted); }
.mono-sm     { font-size: 12px; }
.brand-title { font-size: 19px; }
.fieldset-plain { border: none; padding: 0; margin: 0; }

/* The Add Station chooser: three cards, not a dropdown.
   A System, a Plant and a Site are different things rather than three values of
   one thing - a System is created, a Plant and a Site are assembled from
   Systems that already exist - so each gets room to say what it is. A select
   would hide the distinction behind a word an operator has to already know. */
.station-kind {
  display: block;
  width: 100%;
  text-align: left;
  padding: 0.7rem 0.85rem;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--surface);
  cursor: pointer;
}
.station-kind:hover,
.station-kind:focus-visible { border-color: var(--series-1); }
.station-kind strong { display: block; }
.station-kind small { display: block; margin-top: 0.2rem; line-height: 1.35; }

/* One member per row, the box first, so a column of them scans as a list of
   choices rather than a paragraph of names. */
.check-row {
  display: flex;
  align-items: baseline;
  gap: 0.5rem;
  padding: 0.3rem 0;
}
.check-row input { flex: none; }
.check-row small { margin-left: auto; }
.cell-truncate {
  max-width: 260px;
  overflow: hidden;
  text-overflow: ellipsis;
  font-size: 12px;
  color: var(--ink-muted);
}
.mark-good { color: var(--good); }
.mark-fail { color: var(--critical); }

/* The board toolbar sticks below the topbar.

   Without this, Save scrolls off the top of a tall board - which is precisely
   the board somebody is most likely to be editing. You would drag a card near
   the bottom and then have to scroll back up hunting for the button. Found by
   driving the editor in a real browser; it is invisible to any test that only
   checks the DOM, because the button is present and enabled the whole time. */
.board-toolbar {
  position: sticky;
  top: var(--topbar-h, 66px);
  z-index: 15;
  background: var(--plane);
  padding: 10px 0;
  margin-bottom: 4px;
}

/* Fleet tiles: the reason line, and the fault state.

   `fault` and `stale` share the critical colour but never the mark or the word,
   because on a wall the difference matters: a fault is a plant that is talking
   to us and telling us what is wrong; no signal is a plant we have gone blind
   to, which could be anything. */
.site-tile-detail {
  font-size: 12px;
  line-height: 1.3;
  color: var(--ink-2);
  min-width: 0;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.site-critical .site-tile-detail { color: var(--critical); }
.site-warn .site-tile-detail     { color: var(--ink-2); }

/* OUR COVERAGE, NOT THE PLANT'S STATE — T-371.
   -------------------------------------------------------------------------
   Deliberately quieter than `.site-tile-detail` and deliberately NOT tinted by
   the tile's state: this line says what WE have not asked, and it is equally
   true of a healthy Station and a faulting one. Colouring it with the verdict
   would put it back inside the thing Rainer asked to have it taken out of.

   It wraps rather than truncating. The detail line above is one line by design
   because it sits beside the status word; this one has the tile's full width
   and the sentences are the useful part - "we do not fetch GoodWe alarms yet"
   loses its meaning at "we do not fetch G...". */
.site-tile-coverage {
  font-size: 11px;
  line-height: 1.35;
  color: var(--ink-2);
  opacity: 0.85;
  margin-top: 2px;
  min-width: 0;
}

/* --- wall display ---------------------------------------------------------
   No chrome, no controls, nothing to press. Sized up throughout: this is read
   from across a room, not from a desk.
-------------------------------------------------------------------------- */

body.tv {
  background: var(--plane);
  overflow: hidden;              /* a wall display never scrolls */
  transition: transform 2s linear;   /* burn-in shift, imperceptible */
}
.tv-head {
  display: flex;
  align-items: center;
  gap: 20px;
  padding: 14px 26px;
  border-bottom: 1px solid var(--border);
  background: var(--surface);
}
.tv-titles { display: flex; align-items: baseline; gap: 14px; flex: 1; min-width: 0; }
.tv-titles strong { font-size: 24px; letter-spacing: -0.01em; }
.tv-titles span { font-size: 15px; color: var(--ink-muted); }

/* The screen states its own freshness. A frozen wall showing plausible numbers
   is worse than a blank one, so this turns loud rather than staying subtle. */
.tv-status {
  font-size: 15px;
  color: var(--ink-muted);
  font-variant-numeric: tabular-nums;
  flex: none;
}
.tv-status-bad {
  color: #fff;
  background: var(--critical);
  font-weight: 700;
  padding: 6px 14px;
  border-radius: 6px;
  letter-spacing: 0.04em;
}

.tv-fatal {
  margin: 18px 26px;
  padding: 14px 18px;
  border-radius: var(--radius);
  background: color-mix(in srgb, var(--critical) 14%, transparent);
  color: var(--critical);
  font-size: 18px;
  display: flex;
  gap: 12px;
  align-items: baseline;
}

.tv-main { padding: 18px 26px; }

/* Everything on a wall is one step larger than on a desk. */
body.tv .grid-card .v      { font-size: 40px; }
body.tv .grid-card .v small { font-size: 20px; }
body.tv .grid-card .k      { font-size: 14px; }
body.tv .grid-card .sub    { font-size: 14px; }
body.tv .card-title        { font-size: 14px; }
body.tv .site-name         { font-size: 20px; }
body.tv .site-word         { font-size: 13px; }
body.tv .site-tile-detail  { font-size: 14px; }
body.tv .site-tile-metrics { font-size: 14px; }
body.tv .site-grid {
  grid-template-columns: repeat(auto-fill, minmax(min(280px, 100%), 1fr));
}

/* The hidden attribute must actually hide.

   `hidden` works through `display: none` in the user-agent stylesheet, so ANY
   author rule that sets display beats it. This bit the wall display: the fatal
   banner carried `hidden`, the DOM reported it as hidden, and it was painted on
   screen the whole time because `.tv-fatal` sets `display: flex`.

   The codebase had been patching this per class (.alert[hidden], .tooltip
   [hidden], and three more), which works only until somebody adds a sixth flex
   element and forgets - as happened here. One global rule ends the pattern. */
[hidden] { display: none !important; }

/* Stated rather than hidden: how many site tiles did not fit. */
.site-more {
  margin-top: 8px;
  font-size: 13px;
  font-weight: 600;
  color: var(--ink-2);
}
/* The good case on an exceptions board deserves to look like good news, not
   like an empty card that might have failed to load. */
.site-allclear {
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 20px;
  font-weight: 600;
  color: var(--good);
}
body.tv .site-allclear { font-size: 28px; }
body.tv .site-more     { font-size: 16px; }

/* --- customers page ------------------------------------------------------- */

/* The customer drawer carries a table of sites, which the standard width
   squeezes into unreadability. */
.drawer-wide { width: min(760px, 96vw); }

.row-clickable { cursor: pointer; }
.row-clickable:hover { background: var(--surface-2); }

/* Label/value pairs in the detail view. Two columns so the values line up and
   can be scanned down, rather than hunted for at the end of each line. */
.kv {
  display: grid;
  /* `minmax(0, 1fr)` for the value: a plain `1fr` refuses to shrink below its
     content, and these values are session tokens and email addresses with no
     spaces to break at. On the account page that made every `dd` 196px wide in a
     264px column. */
  grid-template-columns: 120px minmax(0, 1fr);
  gap: 6px 14px;
  margin: 0;
  font-size: 14px;
}
.kv dt { color: var(--ink-muted); }
/* Break inside a long unbroken value rather than push the page wider. `anywhere`
   rather than `break-word` because a 64-character token has no break opportunity
   at all, which is exactly the case this is for. */
.kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }

/* Below the label column's own width there is no room for two columns at all. */
@media (max-width: 420px) {
  .kv { grid-template-columns: minmax(0, 1fr); gap: 2px 0; }
  .kv dt { margin-top: 8px; }
}

.drawer-form { display: flex; flex-direction: column; gap: 14px; }
.drawer-form label { display: flex; flex-direction: column; gap: 5px; font-size: 13px; color: var(--ink-2); }
.drawer-form small { font-weight: 400; }

textarea {
  width: 100%;
  font: inherit;
  padding: 9px 11px;
  border-radius: 8px;
  border: 1px solid var(--border);
  background: var(--surface);
  color: var(--ink);
  resize: vertical;
}
textarea:focus-visible { outline: 2px solid var(--series-1); outline-offset: 1px; border-color: var(--series-1); }

/* A site whose state nobody has established yet. Grey rather than green: it is
   not healthy, it is unknown, and the two must not look alike. */
.pill-unknown { background: color-mix(in srgb, var(--ink-muted) 15%, transparent); color: var(--ink-2); }

/* --- import ---------------------------------------------------------------
   Two columns on a wide screen so the four declarations and the rows they
   describe are visible together: the operator is checking that the sentence
   at the left matches the numbers at the right, and scrolling between them
   is exactly when that check stops happening.
--------------------------------------------------------------------------- */

.import-layout {
  display: grid;
  grid-template-columns: minmax(260px, 340px) 1fr;
  gap: 16px;
  align-items: start;
}

@media (max-width: 860px) {
  .import-layout { grid-template-columns: 1fr; }
}

.import-layout select,
.import-layout textarea { width: 100%; }

textarea {
  font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
  padding: 10px 12px;
  border-radius: 8px;
  border: 1px solid var(--border);
  background: var(--surface);
  color: var(--ink);
  resize: vertical;
  min-height: 180px;
}

textarea:focus-visible {
  outline: 2px solid var(--series-1);
  outline-offset: 1px;
  border-color: var(--series-1);
}

.file-input { font-size: 12px; color: var(--ink-muted); max-width: 220px; }

.import-preview { margin-top: 16px; }

/* Ten figures rather than three, and they wrap: this is the summary the
   operator approves, so nothing in it should need scrolling to reach.
   --------------------------------------------------------------------------
   The columns are sized for the longest value, not the shortest. Several of
   these are date ranges - "1 Jan 2026 to 31 Dec 2026" - not the two- or
   three-digit numbers `.card-metrics` was built for, and `.card-metrics .v`
   sets `white-space: nowrap`. A 120px track plus nowrap plus `min-width: 0`
   does not clip or wrap: it overflows into the neighbouring cell, so the
   period ran through the total and the last timestamp ran through the
   coverage. Wrapping is the fix; a wider minimum keeps most of them on one
   line anyway. */
.import-metrics { grid-template-columns: repeat(auto-fit, minmax(min(150px, 100%), 1fr)); }
.import-metrics .v {
  white-space: normal;
  font-size: 15px;
  line-height: 1.35;
  /* A single unbroken token - a long device name, a pasted URL - must break
     rather than push the grid wider than the card. */
  overflow-wrap: anywhere;
}

.import-warning { margin-top: 14px; }

.import-problems {
  margin-top: 14px;
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 10px 14px;
  background: var(--surface-2);
}

.import-problems summary { cursor: pointer; font-weight: 600; font-size: 13px; }
.import-problems ul { margin: 10px 0 4px 18px; }
.import-problems li {
  font-size: 12px;
  color: var(--ink-2);
  line-height: 1.5;
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.import-problems li.hint { font-family: inherit; color: var(--ink-muted); }
/* Flags sit left, the action pins right.
   The button used to follow the flags directly, so a row with two pills pushed
   its Details button further across than a row with none and the column read as
   ragged. `margin-left: auto` puts every button on the same edge whatever
   precedes it, which is what makes a column of them scannable. */
.row-actions { display: flex; gap: 10px; align-items: center; white-space: nowrap; }
.row-actions > :last-child { margin-left: auto; }

/* A list inside a dialog: addresses about to receive a customer's figures.
   Bulleted rather than run together, because the point is that each one is
   read. */
.plain-list { margin: 4px 0 12px; padding-left: 20px; font-size: 13px; color: var(--ink-2); }
.plain-list li { margin-bottom: 2px; }

/* --- operational limits ---------------------------------------------------
   Four bounds read as one set - a warn_high above a critical_high is nonsense -
   so they sit on one row rather than as four separate fields down a form.
-------------------------------------------------------------------------- */
.limits { border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; margin: 0 0 12px; }
.limits legend { font-size: 12px; font-weight: 600; color: var(--ink-2); padding: 0 4px; }
.limit-row { display: flex; gap: 8px; flex-wrap: wrap; }
.limit { flex: 1; min-width: 110px; margin: 0; }
.limit span { display: block; font-size: 11px; color: var(--ink-muted); margin-bottom: 3px; }
.limit input { padding: 6px 8px; font-size: 13px; }

/* "excluding VAT" beside a total. Quieter than the figure but never hidden -
   a total whose basis is ambiguous is the one somebody pays twice. */
.qualifier { font-weight: 400; color: #555; font-size: 13px; }
.linkish { font-size: 13px; color: var(--series-1); text-decoration: none; }
.linkish:hover { text-decoration: underline; }

/* --- import history -------------------------------------------------------
   Figures are right-aligned and tabular so totals and row counts can be
   compared down the column, which is what this table is read for.
--------------------------------------------------------------------------- */

td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }

/* A heading INSIDE a card, for a panel that carries two tables answering two
   different questions — the Alarms panel's "Where they are" and "Every alert we
   can list". Smaller than the card's own h3 so the card still reads as one
   thing, and spaced above rather than below so it groups with the table it
   introduces instead of floating between them. */
.sub-head {
  margin: 18px 0 6px;
  font-size: 12px;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--ink-2);
}

.chart-head + .sub-head { margin-top: 6px; }

/* A declared interval that disagrees with the file is the mistake this screen
   exists to expose, so it is coloured rather than left to be noticed. */
.mismatch { color: var(--critical); font-weight: 600; }

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

/* --- site detail ----------------------------------------------------------
   The header states what the site is and whether it is all right, in that
   order, because the second question is the one people open the page for.
--------------------------------------------------------------------------- */

.site-head {
  display: flex; align-items: flex-start; gap: 20px;
  margin: 4px 0 18px; flex-wrap: wrap;

  /* RESERVED, AND IT IS WHY THE WHOLE PAGE USED TO JUMP — T-224.
   *
   * Rainer, 2026-08-27: "the refresh and update makes these boxes jump around".
   * Recording every card's top on every animation frame through a load: the cards
   * are painted at 794ms and then shoved DOWN 105px, 248px, 308px. Nothing was
   * wrong with the cards. This box grows 60 -> 126px when renderHeader() fills
   * `#site-sub`, `#health-reason`, `#board-link` and the alarm buttons, and
   * everything below it moves.
   *
   * T-120 did this analysis for `#flow`, `#ov-body` and `.tiles` and reserved each
   * one. The header was never in that list, and it is the easiest of the four:
   * MEASURED ON ALL 34 SITES AT FIVE WIDTHS - 170 samples, per T-120's rule that
   * three sites is a sample and the fleet is a measurement. Above 420px it is 126
   * on every single site. At 420 it wraps to 144-241.
   *
   * 126 IS NOT THE FLOOR, AND THE FLEET SWEEP COULD NOT SEE WHY. Every one of
   * those 170 samples was taken as an account that can manage the site. The
   * buttons in `.site-head-state` are grant-gated, so an account with neither
   * `sites.manage` nor `alerts.manage` settles at **86px** - measured by hiding
   * exactly the buttons the grants hide. Reserving 126 would have hung a
   * permanent 40px hole in the header of every read-only account, which is the
   * trade T-120's comment exists to refuse, one axis over: it says reserve the
   * smallest any STATION needs, and the same argument applies to the smallest any
   * ACCOUNT needs. A sweep of the whole fleet still measured one kind of user.
   *
   * So 86, which cannot open a gap for anybody. On its own that takes the first
   * block's movement from 105px only to 79px - the conservative side of the trade,
   * and the residual is movement, which is temporary, rather than a hole, which is
   * not. DO NOT RAISE IT to close that residual.
   *
   * The rest is closed where it can be closed honestly: `reserve.js`'s
   * `reserveForAccount` remembers what THIS browser's account actually rendered
   * and reserves that on the next visit, the way the per-Station heights already
   * work. Measured: 105px of movement on the old code, 79 with this floor alone,
   * 39 on a second visit with the remembered height, and 0 once `.station-tabs`
   * below was reserved too. */
  min-height: 86px;
}
.site-head h2 { font-size: 22px; letter-spacing: -0.01em; }
.site-head-state { text-align: right; }
.site-head-state .hint { margin-top: 6px; max-width: 34ch; }

.crumb { font-size: 12px; color: var(--ink-muted); margin-bottom: 2px; }
.crumb a { color: var(--ink-muted); text-decoration: none; }
.crumb a:hover { text-decoration: underline; }

.site-sub { display: flex; align-items: center; gap: 10px; margin-top: 7px; flex-wrap: wrap; }

/* Power gets the room; SOC is a companion, not a peer. Two charts rather than
   one dual-axis chart, which this project does not build (LESSONS). */
/* `margin-bottom` as well as top, and it was missing — measured at 0px on
   2026-08-20, so the Overview row sat FLUSH against the Combined power curve
   below it while its own two cards were 16px apart. Reported as "the gap between
   overview and combined power curve is too small", and it was not small, it was
   absent: `.site-cols` set only a top margin and `.card` sets none at all, so
   nothing separated a row from whatever followed it.
   
   24px rather than 16: the row's own gap is what separates cards INSIDE one
   block, and the curve below is the next block. The page's own step for a new
   section is `.section-head`'s 32px, and this sits deliberately between the two —
   the curve is part of the same dashboard, not a new heading. Adjacent sibling
   margins collapse, so where a `.section-head` does follow, its 32px still wins
   and nothing doubles up.
   
   `station-4k.mjs` asserts the gap is at least the row's own internal gap, so the
   vertical rhythm cannot drift from the horizontal one unnoticed again. */
.site-cols {
  display: grid; grid-template-columns: 2fr 1fr; gap: 16px;
  align-items: start;

  /* ONE NUMBER, BOTH DIRECTIONS — T-225.
   *
   * Rainer, 2026-08-27: "The gaps between these boxes needs to be the same", with
   * the two gaps circled where they meet. They were not: 16px between the columns
   * against `margin-bottom: 24px` between one block and the next, which adjacent
   * siblings collapse to 24 rather than adding. Same grid, two gaps, 8px apart -
   * close enough to look like a mistake and far enough to see.
   *
   * `16px` on both margins rather than 0 and 16, because the top one is also the
   * gap between `.tiles` and the first block: `.tiles` carries no bottom margin,
   * so zeroing this would have closed that edge to nothing. Collapsing makes the
   * pair read 16 between blocks and 16 under the tiles. */
  margin-top: 16px; margin-bottom: 16px;
}
@media (max-width: 1000px) { .site-cols { grid-template-columns: 1fr; } }

/* A grid item's default `min-width: auto` is the single most common cause of a
   page that scrolls sideways, and it is why collapsing the columns above was not
   enough: the track went to `1fr`, and its child then refused to go below its own
   content anyway. On a 320px phone `.site-stack` stayed 251px inside a 232px
   track and took the document with it.
   
   Stated on the children rather than as a blanket rule, because `min-width: 0`
   is a real change in what a layout is allowed to do and should be a decision
   per container, not a sweep. */
.site-cols > *, .site-stack > * { min-width: 0; }

/* A column of cards inside one track of `.site-cols`, so the graphs and the
   state-of-charge strip stack under each other while the energy balance holds
   the track beside them (§9). */
.site-stack { display: grid; gap: 16px; align-content: start; }

.kv-wide { grid-template-columns: max-content 1fr; gap: 8px 26px; }

.extra-group { margin-bottom: 18px; }
.extra-group h4 {
  font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em;
  color: var(--ink-muted); margin-bottom: 8px;
}

/* A silenced alarm keeps its place on the page; only its urgency is muted. */
.site-alarm-actions { margin-top: 10px; display: flex; gap: 8px; justify-content: flex-end; }
.site-alarm-actions button[hidden] { display: none; }

/* --- equipment catalogue ---------------------------------------------------
   A retired model still appears: sites reference it, and hiding it would make
   their arrays look like they point at nothing.
--------------------------------------------------------------------------- */

.row-retired td { opacity: 0.55; }
.row-note td { padding-top: 0; border-bottom: 1px solid var(--grid); }
.row-note .alert { margin: 0 0 8px; text-align: left; white-space: normal; }

.check-inline { flex-direction: row !important; align-items: center; gap: 8px; }
.check-inline input { width: auto; margin: 0; accent-color: var(--series-1); }

/* --- settings -------------------------------------------------------------
   The form and the test sit side by side deliberately: configuring mail and
   finding out whether it works are one task, not two.
--------------------------------------------------------------------------- */

.settings-cols {
  display: grid; grid-template-columns: minmax(320px, 1fr) minmax(280px, 380px);
  gap: 16px; align-items: start;
}
@media (max-width: 900px) { .settings-cols { grid-template-columns: 1fr; } }

.settings-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.settings-cols input, .settings-cols select { width: 100%; }

/* ---------------------------------------------------------------------------
   The two fleet dashboards (v5 3C).

   Two pages, one set of styles: /live and /om differ only in which sites they
   list. Built to be read from across a room and to fit as many sites on screen
   as the operator wants - "a list view with adjustable density to fit as many
   sites on screen as possible". Density is a data attribute on the root rather
   than a class per tile, so changing it re-flows without re-rendering.
--------------------------------------------------------------------------- */

.density { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; color: var(--ink-muted); }
.density select { padding: 4px 8px; }

/* The single-line status bar. Wraps rather than scrolls: a counter pushed off
   the edge of a wall display is a counter nobody reads. */
.statusbar {
  display: flex;
  flex-wrap: wrap;
  gap: 8px 18px;
  padding: 10px 14px;
  margin-bottom: 10px;
  border: 1px solid var(--border);
  border-radius: 10px;
  background: var(--surface);
}
.status-item { font-size: 13px; color: var(--ink-muted); }
.status-item strong { font-size: 18px; margin-right: 4px; color: var(--ink); }
/* A zero is deliberately quiet. A row of bright zeroes trains the eye to skip
   the bar, which is where the non-zero one will be. */
.status-zero strong { color: var(--ink-muted); font-weight: 500; }
.status-good strong { color: var(--good); }
.status-warning strong { color: var(--warning); }
.status-bad strong { color: var(--critical); }

.totalsline {
  display: flex;
  flex-wrap: wrap;
  gap: 6px 22px;
  margin-bottom: 14px;
  font-size: 13px;
  color: var(--ink-muted);
}
.totalsline strong { color: var(--ink); font-size: 15px; }

.fleet {
  display: grid;
  /* WIDER, 230 -> 300 (2026-08-20). The minimum decides how many columns a wide
     screen gets; the tracks then stretch to fill it, so on a 2560px board of nine
     Stations the tiles were already 343px and this changes nothing there. Where it
     shows is a FULL board on a large display: at 3840px, 230px gave about thirteen
     columns and 300px gives ten, so each tile is meaningfully wider rather than
     the row simply holding more of them. */
  grid-template-columns: repeat(auto-fill, minmax(min(300px, 100%), 1fr));
  gap: 10px;
  /* Cards size to their content instead of stretching to the tallest in the row.
     Without this, opening a Plant to show its four Systems dragged every card
     beside it to the same height, leaving three tall empty boxes.

     This is why the tiles get a min-height below rather than this getting
     `stretch`: the two requirements pull opposite ways, and only one of them can
     be met by the row. Uniform COLLAPSED cards is what an operator sees; a row
     that grows when one card is opened is what they asked for by opening it. */
  align-items: start;
}

.fleet-tile {
  /* `relative` so the logo can sit in the corner without taking part in the
     layout - see `.fleet-pic` below, and Addendum v3.6 #25. */
  position: relative;
  /* Uniform tiles. Measured on the live fleet before and after: the cards were
     114, 133 and 140px tall depending on whether a Station had a location line
     and how many status icons it carried, which read as a ragged grid.

     A min-height rather than a fixed height, and rather than stretching the row:
     a card whose content genuinely exceeds it - a long name wrapping to two
     lines, or an opened Plant showing its Systems - still grows, and grows
     downward without dragging its neighbours. The floor is what makes the
     ordinary case uniform; the ceiling is deliberately absent because clipping a
     Station's name to keep a grid tidy is the wrong trade. */
  min-height: var(--fleet-tile-min-h, 0px);
  display: flex;
  flex-direction: column;
  padding: 12px 14px;
  border: 1px solid var(--border);
  /* The border colour is the state, as v5 asks. On the left rather than all
     round: a full coloured border on a grid of tiles reads as a grid of
     boxes, and the eye stops seeing which is which. */
  border-left: 4px solid var(--ink-muted);
  border-radius: 10px;
  background: var(--surface);
  color: inherit;
  text-decoration: none;
}
.fleet-tile:hover { border-color: var(--ink-muted); }

.tile-ok       { border-left-color: var(--good); }
.tile-degraded { border-left-color: var(--warning); }
.tile-fault    { border-left-color: var(--critical); }
.tile-stale    { border-left-color: var(--critical); }
.tile-unknown  { border-left-color: var(--border); }
.tile-silenced { border-left-color: var(--ink-muted); }

.fleet-name { display: flex; align-items: center; gap: 8px; margin-bottom: 2px; }
.fleet-name strong { font-size: 15px; }
.fleet-size, .fleet-now { font-size: 13px; color: var(--ink-muted); }
/* The status dots. Sized from a variable so `comfortable` can carry a bigger flag
   without the dense board growing — Rainer: "the alert flags need to be slightly
   bigger in comfortable view". §86 keeps this row at every density on purpose:
   status is the last thing to drop, because it is what a wall of cards exists to
   show. */
.fleet-icons {
  margin-top: 6px;
  min-height: calc(var(--fleet-icon-size, 13px) + 5px);
  display: flex;
  gap: var(--fleet-icon-gap, 6px);
  align-items: center;
}
.fleet-icon { font-size: var(--fleet-icon-size, 13px); line-height: 1; }
.icon-bad { color: var(--critical); }
.icon-warning { color: var(--warning); }
.icon-muted { color: var(--ink-muted); }

/* --- Station cards (hierarchy addendum §8) -------------------------------- */

/* A Plant or Site is marked as one. Muted rather than coloured: the colours on
   this card are all saying something about health, and a second palette
   competing with them is how a wall stops being readable at a glance. */
.pill-kind {
  background: var(--surface-2);
  color: var(--ink-muted);
  border: 1px solid var(--border);
  /* Small, because it competes with the name for a narrow card's first line and
     the name is what is being scanned for. */
  font-size: 10px;
  padding: 1px 5px;
  white-space: nowrap;
}

/* Let a long Station name wrap under its pill rather than squeezing to one word
   per line. */
.fleet-name { flex-wrap: wrap; }
.fleet-name strong { min-width: 0; }

.fleet-where { font-size: 12px; color: var(--ink-muted); margin-bottom: 1px; }

/* The image slot from §83, AND IT MUST NOT CHANGE THE SIZE OF THE CARD.
 *
 * Addendum v3.6 #25: "a card with an image attached must stay the same fixed
 * size as every other card in the grid - the image sits within that fixed
 * footprint (e.g. top-right corner) rather than resizing or distorting the
 * card."
 *
 * It used to be an 84px banner across the top of the card with a negative
 * margin, which added 92px of height to any Station that had a logo. At
 * comfortable density - the only one that shows the picture at all - one card
 * with a logo stood 92px taller than the thirteen beside it. That is the same
 * defect as the attention line was, twice as large, and from the other
 * direction: there the floor was too low, here the content grew.
 *
 * So the picture is taken OUT of the flow. Absolute, in the corner the spec
 * names, over a card that is `position: relative`. The card's height is now
 * decided by its text and nothing else, whether or not there is a logo. */
.fleet-pic:empty { display: none; }
.fleet-pic {
  position: absolute;
  top: 10px;
  right: 12px;
  /* A VARIABLE, per density, and not a literal — 2026-08-20.
   *
   * It was a flat 44px at every density that showed it, which made a customer's
   * mark unreadable on the two screens an operator actually watches. Rainer:
   * "the image icons on live stations and o&m needs to be bigger."
   *
   * Sized from the same place the card's height floor comes from, so the two
   * cannot drift: a bigger picture over an unchanged card floor is how the logo
   * ends up hanging over the status line. */
  width: var(--fleet-pic-size, 56px);
  height: var(--fleet-pic-size, 56px);
  border-radius: 8px;
  background: var(--surface-2) center / cover no-repeat;
  overflow: hidden;
}

/* Room for it, and only where there is one, and only where it is SHOWN.
 *
 * `has-pic` is put on the tile by station-card.js rather than matched with
 * `:has()`, so the rule does not depend on a selector the browser may not have.
 *
 * The second rule is not tidiness. §86 hides the picture at compact and dense,
 * and reserving 52px for it anyway wrapped long Station names onto a second
 * line - which made the card taller, which is the exact defect being fixed,
 * arriving by a different route. Caught by the harness, not by reading. */
/* Derived from the picture's own size rather than restated as a number. The two
   were independent literals (44px and 52px) and would have silently disagreed
   the moment either changed — which is this rule's own failure mode, recorded
   just above: too little room wraps the name, and a wrapped name makes the card
   taller, which is the defect Addendum #25 is about arriving by another route. */
.fleet-tile.has-pic .fleet-name { padding-right: calc(var(--fleet-pic-size, 56px) + 10px); }
:root[data-fleet-density="compact"] .fleet-tile.has-pic .fleet-name,
:root[data-fleet-density="dense"] .fleet-tile.has-pic .fleet-name { padding-right: 0; }

/* The logo itself (§83, B-12).
   `contain` rather than `cover`: this is a mark, not a photograph, and cropping
   somebody's logo to fill a box is worse than showing it small. The padding
   keeps it off the card's rounded corners.
   An <img> rather than a background-image because the CSP discards inline
   styles silently and a per-card URL has nowhere else to live - see the comment
   in station-card.js. */
.fleet-pic img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: contain;
  padding: 8px;
  box-sizing: border-box;
}

/* PV / BESS / percent, side by side. */
.fleet-now { display: flex; flex-wrap: wrap; gap: 2px 10px; }

/* §87: the border already carries state. A major issue - the site has stopped
   reporting, so the monitoring is blind and it could be anything - washes the
   whole card as well, so it is not missed on a grid of ninety.
   `color-mix` against the surface rather than a fixed pink, so it lands
   correctly in both themes instead of glowing in one of them. */
.tile-major {
  background: color-mix(in srgb, var(--critical) 12%, var(--surface));
  border-color: color-mix(in srgb, var(--critical) 35%, var(--border));
}
@supports not (background: color-mix(in srgb, red 10%, blue)) {
  .tile-major { background: var(--surface); opacity: 0.82; }
}

/* A Plant or Site card is a <details>: its Systems are one keystroke away and
   the open/closed state is the browser's, not something to keep in sync. */
.fleet-group { cursor: pointer; }
.fleet-group > summary {
  display: block;      /* not list-item, or the marker sits over the card */
  list-style: none;
  outline-offset: 3px;
}
.fleet-group > summary::-webkit-details-marker { display: none; }

/* THE EXPANDER IS A REAL BUTTON NOW, beside the tier pill — T-98.
 *
 * It was a `::after` glyph on the name row, which was fine as an indicator and
 * useless as a target: the whole card was the `<summary>`, so clicking anywhere
 * expanded it and a Site or Plant could not be OPENED at all, while a System's
 * card was a plain link. A pseudo-element cannot be hit-tested separately, so
 * the indicator had to become an element to become a control.
 *
 * Position is unchanged in spirit — end of the name row, in the flow. Floated
 * with a negative margin it detached from the name the moment the name wrapped
 * to two lines, which is exactly what "CTP Industria Main" does in a narrow
 * card. `.fleet-name` is already a flex row, so `margin-left: auto` is enough. */
.fleet-disclose {
  margin-left: auto;
  align-self: flex-start;
  /* A comfortable target without making the card taller: the button is small,
     its padding does the reaching. 28px square is above the 24px minimum for a
     pointer target and still smaller than the line it sits on. Sized from a
     variable so the density rules can shrink it, and so the card-height floors
     beside them can be reasoned about against one number. */
  width: var(--fleet-disclose-size, 26px);
  height: var(--fleet-disclose-size, 26px);
  flex: 0 0 auto;
  display: grid;
  place-items: center;
  padding: 0;
  border: 1px solid transparent;
  border-radius: 7px;
  background: none;
  color: var(--ink-muted);
  cursor: pointer;
}
.fleet-disclose:hover { border-color: var(--border); background: var(--surface-2); color: var(--ink); }
.fleet-disclose:focus-visible { outline: 2px solid var(--series-1); outline-offset: 2px; }

/* The glyph, rotated rather than swapped: a character change reflows the button
   by whatever the two glyphs' widths differ by, and a control that moves as you
   use it is hard to click twice. */
.fleet-disclose-glyph {
  width: 0; height: 0;
  border-left: 5px solid transparent;
  border-right: 5px solid transparent;
  border-top: 6px solid currentColor;
  transition: transform 120ms ease;
}
.fleet-group[open] > summary .fleet-disclose-glyph { transform: rotate(180deg); }

/* The card is a link again, so it says so — but only over the card, not over the
   button, which does something else. */
.fleet-group > summary { cursor: pointer; }

.fleet-children {
  display: flex;
  flex-direction: column;
  gap: 3px;
  margin-top: 8px;
  padding-top: 8px;
  border-top: 1px solid var(--border);
}
.fleet-child {
  display: flex;
  justify-content: space-between;
  gap: 10px;
  padding: 4px 8px;
  border-left: 3px solid var(--ink-muted);
  border-radius: 5px;
  font-size: 12px;
  color: inherit;
  text-decoration: none;
}
.fleet-child:hover { background: var(--surface-2); }

/* The child rows carry their state, exactly as the cards above them do.
   `.fleet-child` sets the `border-left` SHORTHAND and sits later in this file
   than the `.tile-*` rules at the same specificity, so it was overwriting the
   state colour with the muted grey on every child - every System in an opened
   Plant drew grey whether it was healthy, faulted or dead. The colour is the
   only state a child row shows, so it was showing none.

   Restated at two classes rather than reordered: the `.tile-*` block is shared
   with the cards, and moving it under this rule would make the fix depend on
   file order all over again. */
.fleet-child.tile-ok       { border-left-color: var(--good); }
.fleet-child.tile-degraded { border-left-color: var(--warning); }
.fleet-child.tile-fault    { border-left-color: var(--critical); }
.fleet-child.tile-stale    { border-left-color: var(--critical); }
.fleet-child.tile-unknown  { border-left-color: var(--border); }
.fleet-child.tile-silenced { border-left-color: var(--ink-muted); }

/* A constituent that is itself a grouping opens in place, so a Site's Plant can
   be looked into without leaving the fleet grid. It is a `details`, not an
   anchor, so the row needs the anchor's layout put back on its summary. */
.fleet-child-group { display: block; padding: 0; }
.fleet-child-group > summary {
  display: flex;
  justify-content: space-between;
  gap: 10px;
  align-items: center;
  padding: 4px 8px;
  cursor: pointer;
  list-style: none;
}
.fleet-child-group > summary::-webkit-details-marker { display: none; }
.fleet-child-group > summary::before {
  content: '▸';
  font-size: 9px;
  color: var(--ink-muted);
}
.fleet-child-group[open] > summary::before { content: '▾'; }
.fleet-child-group > summary:hover { background: var(--surface-2); }
/* Indented and hairlined, so two levels of membership read as two levels
   rather than as one longer list. */
.fleet-children-nested {
  margin: 2px 0 4px 14px;
  padding-left: 8px;
  border-left: 1px solid var(--border);
  display: flex;
  flex-direction: column;
  gap: 3px;
}

/* The uniform floor, per density.
   Each is the tallest card the fleet naturally produces at that density,
   measured rather than guessed. Setting one figure for all three would make
   dense cards artificially tall, which defeats the whole point of dense:
   fitting as many on a wall as possible.

   A floor, never a ceiling. A card whose content genuinely exceeds it - a long
   name wrapping, or an opened Plant showing its Systems - still grows. These
   numbers make the ordinary case uniform; they do not clip anything.

   ---------------------------------------------------------------------------
   MEASURE THEM ON A FLEET WITH SOMETHING WRONG WITH IT

   These read 133, 141 and 87 from 2026-08-17 until 2026-08-19, and they were
   measured on an afternoon when all fourteen Stations were healthy. A Station
   that needs attention carries one line the others do not - `.state-since`,
   "not reporting for 3h 13m" - which is 18px plus its margin, so the ONE card
   an operator is looking for was the one card that did not fit the grid. It is
   the second time this grid has gone ragged, and both times the floor was right
   for every card anybody happened to be looking at when it was set.

   Re-measured 2026-08-19 with CTP Pinetown stale: 152, 160 and 87. Dense is
   unchanged because §86 hides `.state-since` there, so its tallest card is
   still its ordinary one - which is the check to repeat if these are ever
   touched again: the floor must be the height of a card in the WORST state that
   density renders, not the commonest.

   `tests/ui/fleet.mjs` asserts exactly that, against a tile it puts into that
   state itself rather than waiting for a real site to be down. */
/* The height floor and the picture size are set together, because they are one
   decision: the picture is absolutely positioned so it does NOT push the card
   taller (Addendum #25 — a card with a logo must be exactly as tall as one
   without), which means the floor has to be tall enough to contain it or the
   mark overhangs the content instead.
 *
 * 2026-08-20: comfortable grew from 160 to 208 and its picture from 44 to 88.
 * Rainer asked for the pictures to be bigger and for comfortable cards to be
 * "bigger still with the images shown properly card height" — and the floor
 * applies to EVERY comfortable card, not only the ones carrying a logo, or the
 * grid goes ragged in exactly the way this variable exists to prevent. */
/* MEASURED, not guessed. A probe read each tile's NATURAL height with the floor
   removed, against the live board:
 *
 *     compact  114-133px      comfortable  122-159px      dense  60-62px
 *
 * The floor has to clear the tallest card or the grid goes ragged — that is the
 * whole reason it exists — and every pixel above the tallest is wasted vertical
 * space on a wall display. The tallest card turned out to be driven by a NAME
 * WRAPPING to two lines and by how many status icons it carries, not by the
 * attention line as first assumed; both vary with the fleet rather than with the
 * CSS, which is why there is headroom above the measurement rather than a snug
 * fit.
 *
 * 2026-08-20, on "the height needs to be less": comfortable 208 -> 184, compact
 * 176 -> 168. Compact had almost no slack and still has none — 168px is itself a
 * card `fleet.mjs` has measured, so anything lower leaves attention cards
 * standing proud of their neighbours. To go shorter than this at compact,
 * CONTENT has to drop, which is what Dense is for (§86). */
:root { --fleet-tile-min-h: 168px; --fleet-pic-size: 56px; --fleet-disclose-size: 26px; }
:root[data-fleet-density="comfortable"] {
  --fleet-tile-min-h: 184px;
  --fleet-pic-size: 88px;
  /* 13 -> 17px, with the gap widened to match: at 13px on a 330px-wide card the
     flags read as punctuation rather than as status. The row's own min-height is
     derived from this, so the card floor above still contains it. */
  --fleet-icon-size: 17px;
  --fleet-icon-gap: 9px;
}
/* The expander is a real button now (T-98), so it sets a floor on the name row —
   26px against the ~20px a line of text occupied. That raised the tallest cards
   above the old 152px floor and `fleet.mjs` caught it immediately: "a Station
   needing attention is 168px against 152px for the rest of the grid". The floor
   is what makes the grid uniform, so it moves with the content rather than the
   content being trimmed to fit it. Smaller at dense, where the whole card is
   87px and a 26px control would be a third of it. */
:root[data-fleet-density="dense"] { --fleet-disclose-size: 20px; }
/* Compact and dense hide the picture entirely (§86), so neither needs a size —
   they inherit one and never use it. */
:root[data-fleet-density="dense"]       { --fleet-tile-min-h: 87px; }

/* Density. Comfortable is for a desk, dense is for a wall with a hundred
   sites on it. */
/* Wider too, from 280 to 330: at 280 an 88px mark is nearly a third of the
   card's width and crowds the name row it has to sit beside. `min(…, 100%)` is
   kept, so a phone still gets one full-width column rather than a card wider
   than the screen. */
/* Widened again to 420 on 2026-08-20 — at 3840px about seven columns rather than
   eleven, which is what "wider" means on the display this is read on. */
:root[data-fleet-density="comfortable"] .fleet { grid-template-columns: repeat(auto-fill, minmax(min(420px, 100%), 1fr)); gap: 14px; }
:root[data-fleet-density="comfortable"] .fleet-tile { padding: 16px 18px; }

:root[data-fleet-density="dense"] .fleet { grid-template-columns: repeat(auto-fill, minmax(min(170px, 100%), 1fr)); gap: 6px; }
:root[data-fleet-density="dense"] .fleet-tile { padding: 7px 9px; border-radius: 7px; }
:root[data-fleet-density="dense"] .fleet-name strong { font-size: 13px; }
:root[data-fleet-density="dense"] .fleet-size,
:root[data-fleet-density="dense"] .fleet-now { font-size: 11px; }
/* §86 gives the order detail is dropped in as space runs out: picture first,
   then the address, then the size and spec details, then status LAST. Status is
   the thing a fleet wall exists to show, so it survives to the end - which is
   why the icon row and the state border are never in this list.

   Done in CSS rather than in the template so the density control changes what is
   visible without re-rendering the grid; what to show at a given size is a
   question about the screen, not about the data. */
:root[data-fleet-density="compact"] .fleet-pic { display: none; }

:root[data-fleet-density="dense"] .fleet-pic,
:root[data-fleet-density="dense"] .fleet-where { display: none; }
/* The "now" figure goes with the specs: at this density the operator is scanning
   for colour, not reading numbers. */
:root[data-fleet-density="dense"] .fleet-size,
:root[data-fleet-density="dense"] .fleet-now { display: none; }

/* Narrow screens run out of room the same way, and follow the same order. The
   card grid is one column here, so the picture is worth keeping longest. */
@media (max-width: 700px) {
  .fleet-where { display: none; }
}

/* ---------------------------------------------------------------------------
   Station map (hierarchy addendum §79)

   No tile layer and no external origin - the basemap is a vendored outline, so
   everything here is a path and a circle. See assets/map.js for why.
--------------------------------------------------------------------------- */

.map-layout {
  /* Three columns since 2026-08-19: panel, map, panel (Addendum v3.6 #19).
     The map keeps the flexible column so it takes the room a wide screen
     gives - it is the thing being looked at. */
  display: grid;
  grid-template-columns: minmax(220px, 300px) minmax(320px, 1fr) minmax(240px, 320px);
  gap: 16px;
  align-items: start;
}
/* A phone has no left and right. Both panels stack, and the map goes first
   because it is what the page is for. */
@media (max-width: 1200px) {
  .map-layout { grid-template-columns: minmax(320px, 1fr) minmax(240px, 320px); }
  .map-aside-left { grid-column: 1 / -1; order: -1; }
  .map-aside-left .fleet-tiles { grid-template-columns: repeat(auto-fit, minmax(min(150px, 100%), 1fr)); }
}
@media (max-width: 900px) { .map-layout { grid-template-columns: 1fr; } }

/* Stacked in the left panel rather than side by side, which is what the old
   `.fleet-panel` band did with the same two blocks. */
.map-aside-left .fleet-tiles { grid-template-columns: 1fr; }
.map-aside-left .fleet-roll { margin-top: 12px; }

.map-frame {
  position: relative;
  border: 1px solid var(--border);
  border-radius: 10px;
  background: var(--surface);
  overflow: hidden;
}

#map { display: block; width: 100%; height: auto; touch-action: none; cursor: grab; }
#map.dragging { cursor: grabbing; }

/* The land itself. Deliberately quiet: it is there to say which part of the
   country a pin is in, and a strongly coloured landmass competes with the pins,
   which are the only thing on this page carrying information. */
.map-land {
  fill: var(--surface-2);
  stroke: var(--border);
  stroke-width: 1.2;
  vector-effect: non-scaling-stroke;
}

.pin { cursor: pointer; }
.pin:focus { outline: none; }
.pin-halo { fill: currentColor; opacity: 0.22; }
.pin-dot { fill: currentColor; stroke: var(--surface); stroke-width: 1; vector-effect: non-scaling-stroke; }
.pin-count { fill: var(--surface); text-anchor: middle; font-weight: 700; }
.pin:hover .pin-halo, .pin:focus .pin-halo { opacity: 0.4; }

/* Same vocabulary as a card's state border, so a Station reads the same on both
   screens. `currentColor` lets one rule paint halo, dot and count together. */
.pin-ok       { color: var(--good); }
.pin-degraded { color: var(--warning); }
.pin-fault    { color: var(--critical); }
.pin-stale    { color: var(--critical); }
.pin-unknown  { color: var(--ink-muted); }
.pin-silenced { color: var(--ink-muted); }

.map-tip {
  position: absolute;
  z-index: 2;
  max-width: 280px;
  padding: 8px 10px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--surface);
  box-shadow: 0 6px 20px rgb(0 0 0 / 18%);
  font-size: 13px;
  pointer-events: none;
}

.map-aside h3 { font-size: 14px; margin: 0 0 6px; }
.map-aside > div + div { margin-top: 18px; }

/* T-84 — a left-panel widget the user can move or hide, and the bar that does it.

   The bar stays QUIET until the panel is hovered or something in it has keyboard
   focus: this column is read all day and looked at far more often than it is
   rearranged, so controls at full contrast would compete with the figures they
   sit above. `:focus-within` is the half that makes it reachable without a
   mouse — opacity alone would leave a keyboard user tabbing into buttons they
   cannot see. */
.map-widget + .map-widget { margin-top: 18px; }
.map-widget[hidden] { display: none; }

.widget-bar {
  display: flex;
  align-items: center;
  gap: 4px;
  margin-bottom: 4px;
  min-height: 20px;
  opacity: 0;
  transition: opacity 120ms ease-in-out;
}

.map-widget:hover > .widget-bar,
.map-widget:focus-within > .widget-bar { opacity: 1; }

@media (prefers-reduced-motion: reduce) {
  .widget-bar { transition: none; }
}

.widget-name {
  font-size: 11px;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--ink-muted);
}

.widget-btn {
  font: inherit;
  font-size: 11px;
  line-height: 1;
  padding: 3px 6px;
  border: 1px solid var(--border);
  border-radius: 4px;
  background: var(--surface);
  color: var(--ink);
  cursor: pointer;
}

.widget-btn:hover:not(:disabled) { border-color: var(--series-1); }
.widget-btn:disabled { opacity: 0.35; cursor: default; }

/* The way back from a hidden panel. Always visible when it has anything in it —
   the point of the tray is to be findable by somebody who has just made a panel
   disappear and wants it back. */
.widget-tray {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 6px;
  margin-top: 18px;
  padding-top: 10px;
  border-top: 1px solid var(--border);
}

.widget-tray[hidden] { display: none; }

/* A heading row inside a panel, for the ones that own a control — the period
   selector belongs beside "Revenue & energy" rather than above the whole page,
   which is where it sat while these widgets lived under the map. */
.map-panel-head {
  display: flex;
  align-items: baseline;
  gap: 8px;
  margin-top: 18px;
}
.map-panel-head:first-child { margin-top: 0; }
.map-panel-head h3 { margin: 0; }
.map-panel-head select { max-width: 120px; }
/* The reference's "More" on the Alarms panel. Right-aligned against the heading
   in the same row, which is why the head is a flex container already. */
.panel-more { margin-left: auto; font-size: 12.5px; color: var(--ink-muted); }
.panel-more:hover { color: var(--ink); }

/* --- the left panel's three widgets (#38) --------------------------------- */

/* The Power gauge. A 240-degree arc with the gap at the bottom, where the reading
   sits — the shape the reference uses. Stroked rather than filled so the track and
   the value are one geometry with two colours. */
.gauge { width: 100%; max-width: 190px; margin: 0 auto; display: block; }
.gauge-track, .gauge-fill { fill: none; stroke-linecap: round; stroke-width: 11; }
.gauge-track { stroke: var(--surface-2); }
.gauge-fill  { stroke: var(--series-1); }
/* THE DIAL'S TICKS AND NEEDLE - #38's Power gauge, which is one of the two
   widgets Reinier singled out. A ticked dial reads as an instrument: it says where
   the needle sits between gradations, which the filled arc this replaced did not.

   The lit ticks are those the needle has passed, so the value is carried by tick
   LENGTH and by position as well as by colour - the reading survives a reader who
   cannot separate the accent from the muted stroke. */
.gauge-tick { stroke: var(--surface-2); stroke-width: 2; stroke-linecap: round; }
.gauge-tick.is-major { stroke-width: 2.5; }
.gauge-tick.is-lit { stroke: var(--series-1); }
.gauge-needle { stroke: var(--ink); stroke-width: 2.5; stroke-linecap: round; }
/* 15px, not 17: at 17 a four-character megawatt reading reached the ticks on
   both sides. Measured on the live fleet at 1.76 MW. */
.gauge-value { fill: var(--ink); font-size: 15px; font-weight: 600; }
.gauge-sub   { fill: var(--ink-muted); font-size: 10px; }

/* The Alarms ring, beside its own legend. Wraps under on a narrow panel rather
   than shrinking the ring to nothing. */
.ring-wrap { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
.ring { width: 104px; height: 104px; flex: 0 0 auto; }
.ring-track { fill: none; stroke: var(--surface-2); stroke-width: 14; }
.ring-slice { fill: none; stroke-width: 14; }
.ring-value { fill: var(--ink); font-size: 20px; font-weight: 600; }
.ring-sub   { fill: var(--ink-muted); font-size: 9px; }

/* OUR severity vocabulary, not the reference platform's Fault/Alarm/Prompt — the
   colours are the ones the card borders already use for the same states, so a
   slice and a tile mean the same thing. */
.ring-fault    { stroke: var(--critical); }
.ring-stale    { stroke: var(--critical); opacity: .62; }
.ring-degraded { stroke: var(--warning); }

.ring-legend { flex: 1 1 130px; min-width: 0; margin: 0; padding: 0; list-style: none; font-size: 12.5px; }
.ring-legend li { display: flex; align-items: center; gap: 7px; padding: 3px 0; }
.ring-legend strong { margin-left: auto; font-variant-numeric: tabular-nums; }
.ring-swatch { width: 10px; height: 10px; border-radius: 2px; flex: 0 0 auto; }
.ring-swatch.ring-fault    { background: var(--critical); }
.ring-swatch.ring-stale    { background: var(--critical); opacity: .62; }
.ring-swatch.ring-degraded { background: var(--warning); }

/* The Environmental row. A GRID rather than a single figure, so the two tiles
   whose factors do not exist yet (coal saved, trees planted) drop in beside CO2
   without this being rewritten. */
.env-row {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(88px, 1fr));
  gap: 10px;
}
.env-tile {
  background: var(--surface-2);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 10px 11px;
}

/* The right panel now carries two widgets and two notices, so it scrolls with the
   map rather than running the page long. Capped against the map's own height,
   which is what keeps the three columns reading as one instrument panel. */
.map-aside { min-width: 0; }
.map-aside #fleet-chart svg,
.map-aside #fleet-chart canvas { max-width: 100%; }
.map-list { margin: 6px 0 0; padding-left: 18px; font-size: 13px; }
.map-list li { margin-bottom: 3px; }
.map-note code { font-size: 12px; }

/* ---------------------------------------------------------------------------
   Flow diagram and Overview card (v5 3C, hierarchy addendum §9)
--------------------------------------------------------------------------- */

/* The diagram and the Overview sit side by side, and unlike the charts below
   they stack sooner: the Overview is a figure grid that becomes unreadable long
   before the page is narrow. */
/* BLOCK 1: Power flow above Combined power on the left, Overview on the right.
   
   The left track is the wider one because it carries the flow diagram, which
   measures its own container and draws wires between four boxes; the Overview is
   a column of figures and reads fine narrower. Taken from 1.5fr to 1.9fr on
   2026-08-20 — "we can reduce the width of the overview a little" — which at
   3840px moves about 150px from the right track to the left. */
.site-cols-overview { grid-template-columns: minmax(320px, 1.9fr) minmax(260px, 1fr); }

/* BLOCK 2: the Combined power curve, with Energy balance beside it.
   
   THE SAME TRACKS AS BLOCK 1, deliberately — 1.9fr / 1fr rather than the 1.55fr it
   started with. The two right-hand cards sit one above the other down the right of
   the page, and at different widths they read as a mistake rather than as a
   column. Reported as "the energy balance is wider than the overview, they need to
   be same width".
   
   Stated as its own rule rather than folded into `.site-cols-overview`: the two
   blocks hold different things and may want to diverge again, and a shared class
   would make that a choice between changing both or unpicking one. */
.site-cols-curve { grid-template-columns: minmax(320px, 1.9fr) minmax(260px, 1fr); }
@media (max-width: 1100px) { .site-cols-curve { grid-template-columns: 1fr; } }

/* A CARD THAT STANDS THE FULL HEIGHT OF WHATEVER IS BESIDE IT.
 *
 * `.site-cols` sets `align-items: start`, which is right nearly everywhere and
 * wrong for the two cards that use this: a short card against a tall neighbour
 * leaves a gap under it that reads as a mistake.
 *
 * Used in BOTH blocks now — the ring against the chart, and the Overview against
 * the Power-flow-and-Combined-power stack. It is still per-CARD rather than
 * `align-items: stretch` on the row, because a row-level rule would also stretch
 * the STACK, and a stack stretched to its own height does nothing useful while
 * making the two rules impossible to tell apart later.
 *
 * The card becomes a flex column so its heading stays at the top and the body
 * takes the remaining height, instead of the ring floating in the middle of a
 * tall box. `min-height: 0` on the body because a flex child's default
 * `min-height: auto` refuses to shrink below its content and would push the card
 * taller than the chart it is matching. */
.site-cols > .card-match {
  align-self: stretch;
  display: flex;
  flex-direction: column;
}
.site-cols > .card-match > :last-child { flex: 1 1 auto; min-height: 0; }

/* Below the breakpoint the row is one column and there is nothing beside it to
   match, so the card goes back to its own height. */
@media (max-width: 1100px) {
  .site-cols > .card-match { align-self: start; }
}


/* THE DIAGRAM SITS IN THE MIDDLE OF WHATEVER HEIGHT IT IS GIVEN - T-227.
   
   T-218 made this card stretch to the Overview beside it, and nothing inside it
   stretched with it: `#flow` grew to 400px while `.flow-cross` stayed 306, leaving
   94px of dead space UNDER the diagram on The Conservatory and 68px on David Test.
   Rainer: "the power flow is not expanding nicely to the size". A card that grows
   and leaves its contents pinned to the top reads as a sagging box rather than a
   deliberate one.
   
   CENTRED RATHER THAN SPREAD, and the choice is forced by the line above. Filling
   the space by distributing the rows - `align-content: space-evenly` - would make
   the VISUAL gap between Solar and the Grid/Load row grow with the card, so the
   16px he asked for would hold only on the shortest Station. One instruction has
   to give way to the other, and the gap is the one he stated as a number.
   
   So the diagram keeps its own size with its 16px gaps intact, and the slack is
   split evenly above and below it. */
#flow { display: flex; flex-direction: column; justify-content: center; }

/* BLOCK 1 TAKES THE TALLER CARD - T-218.
   
   Rainer, 2026-08-27, asked whether Power flow and Overview should be the same
   height: "yes they should be the same height". They were not, and by a lot.
   
   MEASURED BEFORE CHOOSING A DIRECTION, four Stations x four widths:
   
     Conservatory   4K 455/590   1440p 455/590   1080p 455/536   1366 455/697
     CTP Pinetown   4K 530/690   1440p 530/690   1080p 530/690   1366 530/690
     SGS Randfontein 4K 356/461  1440p 356/461   1080p 356/461   1366 356/479
     David Test     4K 454/454   1440p 455/455   1080p 455/479   1366 455/640
   
   Two facts decide it. **Overview is the taller in all sixteen** - never once
   shorter - and **Power flow is near-constant per Station** while Overview reflows
   with width, 454 to 697 on one Station. So "same height" can only mean the row
   takes Overview and Power flow grows into it: capping Overview would make a stat
   grid lose content or scroll at some widths and not others.
   
   THAT IS THE BATTERY ROW'S RULE, and it was chosen by looking at both options a
   few hours earlier - "a chart stretches harmlessly, a stat grid does not
   compress". Block 1 is the same shape with the same taller side, so it gets the
   same answer rather than a second opinion.
   
   `1 1 auto` AND NOT `1 1 0`, WHICH IS THE OPPOSITE OF BLOCK 3 AND DELIBERATE.
   Block 3 uses a zero basis because its chart reads its holder's height, so the
   holder must be an INPUT rather than an echo (T-223). `#flow` is not that shape:
   `drawWires()` READS `.flow-cross`'s box and draws to fit, and never sets the
   container's height from its own content - checked, not assumed.
   
   And a zero basis here does real damage, measured: it collapses the flow card's
   NATURAL height to head-plus-floor, so on a Station where the diagram is the
   taller card the row shrinks to the Overview and the diagram is squashed. CTP
   Pinetown went 530/530 to 410/410 and David Test 454/454 to 425/425 - both
   already matched, both made 120px and 29px shorter by a change whose whole
   purpose was to stop cards disagreeing. `auto` keeps each card's own content as
   the floor, so the row is the taller of the two and neither is compressed.
   
   SAFE FOR THE DIAGRAM, checked rather than assumed: `drawWires()` READS
   `.flow-cross`'s box and draws to fit, and re-measures on resize - it never sets
   the container's height from its own content, so there is no echo of the kind
   that made the SOC chart creep. A taller card gives a larger diagram, not a gap
   under one.
   
   `card-match` is NOT used here, and the note below says why it must not be: it
   matched a STACK on 22 August and stood Energy balance at 960px against a 550px
   graph. Block 1 is a stack - the flow diagram sits under a weather card in the
   same section - which is exactly the shape that failed. */
.site-cols-overview { align-items: stretch; }

.site-cols-overview > .card {
  display: flex;
  flex-direction: column;
}

/* THE OVERRIDE IS NOT DELETED, IT READS THE RESERVATION - T-120 / T-218.

   `flex: 1 1 auto` with a floor of zero is what lets this child shrink so the
   Power flow card matches the Overview beside it, and deleting it reopens T-218
   and T-227. It also, at (1,2,0), silently killed T-120's remembered height for
   this one container: `3a525e5c` landed the reservation at 20:13 on 2026-08-27
   and `90a5eed0` - a card-matching change - disabled it at 20:35 the same
   evening. Nothing noticed for ten days, because the harness was passing on a
   warm cache, where the content arrives before the frames it asserts about.

   Rainer chose (b) on 2026-09-10: the reservation travels through a custom
   property that this rule consumes, rather than reserve-boot trying to out-
   specify a selector it would then have to keep up with. So the layout still
   owns the declaration and the value comes from `#flow`'s `--ms-reserve-h`,
   which is `0px` in this stylesheet and the remembered height while the
   container is empty. No fallback here on purpose: the token is defined above,
   and a fallback on a defined token is what hid T-307 (see `CssTokenTest`). */
.site-cols-overview > .card > #flow { flex: 1 1 auto; min-height: var(--ms-reserve-h); }

/* A COLLAPSED GROUP HEADING IS THE PAGE - T-232.
   
   With every manufacturer shut by default, these six rows are what somebody sees
   when Connections opens, so they have to read as a list of vendors rather than as
   a table separator with a lozenge in it. The name was `connector_key` in a muted
   pill - small, grey, lowercase - which was right when it labelled a group that
   was already open beneath it and wrong the moment it became the thing you scan.
   
   Rainer, 2026-08-27: "make the headings easier to read".
   
   The whole heading is the button, so the target is the row rather than a caret a
   few pixels wide - a collapse control that needs aiming is one people stop using. */
.group-toggle {
  display: flex;
  align-items: center;
  gap: 8px;
  width: 100%;
  padding: 2px 0;
  font: inherit;
  text-align: left;
  cursor: pointer;
}

.group-toggle .group-name {
  font-size: 1.05rem;
  font-weight: 600;
  letter-spacing: 0.01em;
}

/* The caret carries the state on its own, so the heading still reads open or shut
   in a screenshot, in high contrast, and to anyone who does not see the colour
   difference between a row and its background. */
.group-toggle[aria-expanded='false'] { opacity: 0.85; }

/* THE BATTERY ROW - T-216, Reinier's UI-UPDATES v1.0.
   
   Battery SOC and Battery health, side by side in the same tracks as the two
   blocks above, so SOC keeps the width and left edge it already shared with the
   power curve. That alignment is what defined the empty slot in the first place.
   
   STRETCH, AND DELIBERATELY NOT `card-match`. The spec says row heights follow
   SOC, and that is right until health is the taller of the two - which it is at
   desktop widths, because six stats that sit on one line at full width wrap to
   three rows in a third of the page. So the row takes the taller card and SOC
   grows into it: a chart stretches harmlessly, a stat grid does not compress.

   CHOSEN BY LOOKING, 2026-08-27. Both layouts shipped behind a temporary selector
   for a few hours - this one and a `natural heights` variant that let each card
   keep its own height and left the row ragged. Rainer compared them on the page
   and picked matched; the selector and the alternative rule are gone, and this is
   now simply the rule. Recorded because the empty space under a flat SOC line is
   a deliberate trade rather than an oversight, and the next reader will wonder.
   
   `card-match` is the mechanism that must NOT be used here. It matched a STACK on
   22 August and stood Energy balance at 960px against a 550px graph, which is the
   bug that moved SOC out to full width and created this slot. Reaching for it
   again would rebuild that failure one row lower. */
/* BLOCK 3: Battery SOC with Battery health beside it — T-216, aligned T-219.
   
   THE SAME TRACKS AS BLOCKS 1 AND 2, and stated separately for the reason block 2
   gives: the three hold different things and may want to diverge, and a shared
   class would make that a choice between changing all three or unpicking one.
   
   IT SHIPPED WITH THE BASE `.site-cols` TRACKS - 2fr/1fr against their 1.9fr/1fr -
   and 2fr is not 1.9fr. The seam between the columns therefore sat in a different
   place on this row, by 14px at 1500 and 41px at 3840, WIDENING with the display.
   Rainer, 2026-08-27: "boxes not lined up". It is the same report block 2 already
   carries - "the energy balance is wider than the overview, they need to be same
   width" - arriving a third time because the third block was added without
   checking the first two. The commit that added it claimed "the same tracks as the
   blocks above" and nobody had measured it. */
.site-cols-battery { grid-template-columns: minmax(320px, 1.9fr) minmax(260px, 1fr); }
@media (max-width: 1100px) { .site-cols-battery { grid-template-columns: 1fr; } }

.site-cols-battery { align-items: stretch; }

.site-cols-battery > .card {
  display: flex;
  flex-direction: column;
}

/* The chart is the part that gives. Its holder takes the space left after the
   head, so a taller neighbour lengthens the curve rather than leaving a gap under
   it.

   `1 1 0` AND NOT `1 1 auto`, and the difference is the bug this shipped with —
   T-223. Under `auto` the holder is at least as tall as its content, and its
   content is the canvas whose height the chart sets from this holder: each draw
   read a height that included the last one, so the chart crept taller every time
   anything redrew it. A zero basis takes the holder's height purely from the free
   space in the card, which is what makes it an INPUT to the chart rather than an
   echo of it. The floor below is what the card is worth on its own when Battery
   health beside it is shorter. */
.site-cols-battery > #soc-card > .chart-holder {
  flex: 1 1 0;
  min-height: 200px;
}

/* A CARD BEING REFRESHED KEEPS ITS FIGURES, DIMMED — T-222. See the long note in
   station-view.js `mountOverview`: the alternative, and what was there, is an
   empty card for the length of a fetch, which collapses the stretched row it sits
   in and moves every box under it. */
.is-reloading { opacity: 0.55; transition: opacity 120ms ease-in; }

/* THE RIGHT COLUMN STOPS GROWING ON A BIG DISPLAY — T-219.
   
   Rainer, 2026-08-27: "once we move to larger resolutions the boxes on the right
   needs to lock in size end left needs to get longer."
   
   At 1.9fr/1fr the right track keeps its third of whatever it is given: 423px at
   1500, 568 at 1920, 789 at 2560, 1230 at 3840. The cards in it do not want that
   width. Energy balance is a donut that stops growing at its own size, and Battery
   health is six stats that reflow to more and more columns until they are a row of
   labels adrift in white space. The LEFT column is where width earns something —
   the power curve gets more x-axis, the flow diagram more room between its boxes,
   the SOC trace more resolution.
   
   So beyond 2000px the right track is fixed and the left takes everything else.
   
   THE BREAKPOINT AND THE WIDTH ARE CHOSEN TO MEET, not picked separately: at
   2000px the 1fr track is already close to 600px, so the lock engages at about the
   width the column would have had anyway. Measured across the boundary rather than
   asserted: 589px at 1980, 600px at 2000, 600px at 2020 — an 11px step, not a
   jump. 568px at 1920 and 789px at 2560 bracket the choice.
   
   All three blocks together, and this is the one place where sharing is right:
   their alignment IS the requirement, so a rule that could move one without the
   others would reintroduce exactly what T-219 fixed. */
@media (min-width: 2000px) {
  .site-cols-overview,
  .site-cols-curve,
  .site-cols-battery {
    grid-template-columns: minmax(320px, 1fr) 600px;
  }
}

/* Nothing here sets the stat grid's column count: `.import-metrics` is already
   `repeat(auto-fit, minmax(min(150px, 100%), 1fr))`, so six stats become two
   columns in a 423px track at 1500px and three at 1920 without a line of CSS.
   Measured before writing this rather than assumed - the spec asked for a
   2-column grid and the stylesheet already had one. */

/* `.card-fill` and `.card-span` lived here until 2026-08-20 and are GONE with the
   layout that needed them. The row is now two `.site-stack` columns, so each side
   flows on its own and nothing has to be stretched or spanned to fill a track.
   Removed rather than left: unused CSS is read as load-bearing by the next person
   and copied into the next screen. */
@media (max-width: 1100px) { .site-cols-overview { grid-template-columns: 1fr; } }

/* §9's cross: Solar above, Grid left, Load right, Battery below, meeting at a
   junction. Areas rather than a flat run, so a Station without one of them
   leaves an empty cell and the rest keep their positions - the diagram must not
   rearrange itself depending on what the site happens to have. */
.flow-cross {
  display: grid;
  /* `minmax(0, 1fr)` rather than `1fr`: a grid track's default minimum is its
     content, so the side boxes refused to shrink and pushed themselves out of
     the cross - about 20px each side, which the card's own padding hid well
     enough that it only showed up when the boxes were measured against the
     card rather than looked at. */
  /* The middle column is a fixed spacer now that nothing sits in it. It was
     `auto`, sized by whatever the junction dot happened to be; with the dot gone
     `auto` would collapse to zero and pull the Grid and Load boxes 10px closer
     together for no stated reason. A number says what the gap is. */
  grid-template-columns: minmax(0, 1fr) 10px minmax(0, 1fr);
  /* Solar and Battery SPAN all three columns rather than sitting in the middle
     one, and centre themselves inside it.
     
     They used to occupy the centre column, which is `auto` - so the track sized
     itself to the Solar box (173px measured) instead of to the 10px junction,
     and the two side columns were left 128px each for content that needs 148.
     The Grid and Load boxes then overflowed by 20px a side, hidden by the card's
     own padding until they were measured against it rather than looked at.
     
     Spanning costs nothing visually: `justify-items: center` already puts them
     over the junction. */
  grid-template-areas:
    "pv   pv   pv"
    "grid gap  load"
    "batt batt batt";
  align-items: center;
  justify-items: center;
  /* Room for the wires to bow through. The old 6px/10px left the boxes almost
     touching the junction, which is fine for an arrow glyph and leaves a curve
     nowhere to go - it would have been drawn as a kink rather than an arc.

     The vertical gap can be generous because nothing competes for height. The
     horizontal one cannot: `1fr auto 1fr` plus two boxes plus this gap has to
     fit the card, and the first version of this used 44px and pushed the Load
     box out of the right-hand edge. Measured rather than guessed - a node that
     overflows its card is invisible in a screenshot cropped to the diagram. */
  /* ONE NUMBER, BOTH DIRECTIONS - T-227, and it is 30 rather than the 16 first asked
     for. This is the second time a gap on this page has been settled by looking at
     it rather than by stating it; T-225 is the first.
     
     Rainer asked for the gaps between Solar, Grid, Load and Battery to match the
     gap between the Power flow and Overview cards, which is 16px, and said so
     twice - to this session and again over chat. Both answers were given from a
     DESCRIPTION. When another session rendered the three options side by side he
     changed it to 30/30, and that is the answer that stands, because it is the one
     made with the picture in front of him.
     
     WHAT THE PICTURE SHOWED, measured edge to edge between the Grid and Load boxes:
     
         30 / 56  (before)   122px   the asymmetry he circled in the first place
         16 / 16              42px   boxes almost touching, the wires lose their arc
         30 / 30              70px   symmetric, and the arcs survive
     
     16px lands nearer the old 6px/10px that the note below records as drawing "a
     kink rather than an arc" than it does to anything that works. The instruction
     was about SYMMETRY - one number instead of two - and 30/30 satisfies that while
     16/16 also breaks the wires.
     
     THE 44px UPPER BOUND IN THE OLD NOTE IS GONE, and it was an artefact rather
     than a measurement: it was true when the middle column was `auto` and sized
     itself to the Solar box at 173px. With that column a fixed 10px, nothing
     overflows at any value tried - the cross measures 1374px inside a 1412px card
     at 2284, and reducing the gap only ever frees space. Left recorded because the
     next person to touch this will otherwise re-derive it. */
  gap: 30px;
  padding: 8px 0;
  /* The wire overlay is positioned against this. */
  position: relative;
}

.flow-cell-pv   { grid-area: pv; }
/* `min-width: 0` so a long value can shrink the box rather than overflow the
   card. A grid item's default `min-width: auto` refuses to go below its content,
   which is how a wide reading pushes the whole row sideways. */
.flow-cell-grid { grid-area: grid; display: flex; align-items: center; gap: 6px; justify-self: end; min-width: 0; }

.flow-cell-load { grid-area: load; display: flex; align-items: center; gap: 6px; justify-self: start; min-width: 0; }
.flow-cell-batt { grid-area: batt; }

/* The vertical pair stack their arrow between themselves and the junction; the
   horizontal pair put theirs beside it, which is what the flex above is for.

   The battery's arrow used to sit UNDER its box - `column-reverse` put the node
   first and the arrow second - which placed the marker on the far side of the
   battery from the junction it describes. Every other leg has its arrow between
   the box and the middle, and the battery now does too: the DOM order is arrow
   then node, and dropping the reverse is what puts it above. */
.flow-cell-pv, .flow-cell-batt { display: flex; flex-direction: column; align-items: center; gap: 4px; }

/* The junction dot is gone. It said "the lines meet at a point", which was true
   of four legs running to a junction and is not true of six lines between four
   boxes: two cross in the middle and the other four pass nowhere near it. A mark
   there now asserts a meeting that does not happen. */




.flow { display: flex; flex-direction: column; align-items: stretch; gap: 2px; }
.flow-row { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
.flow-centre { grid-template-columns: 1fr; justify-items: center; }
.flow-centre .flow-node { max-width: 220px; width: 100%; }

.flow-node {
  display: grid;
  grid-template-columns: 28px 1fr;
  grid-template-areas: "icon label" "icon value" "icon sub";
  align-content: center;
  gap: 0 10px;
  padding: 10px 12px;
  border: 1px solid var(--border);
  border-radius: 10px;
  background: var(--surface);
  min-width: 0;
}
.flow-icon { grid-area: icon; align-self: center; color: var(--ink-muted); }
.flow-icon svg { width: 20px; height: 20px; fill: currentColor; display: block; }
.flow-label { grid-area: label; font-size: 12px; color: var(--ink-muted); }
.flow-value { grid-area: value; font-size: 17px; font-weight: 600; font-variant-numeric: tabular-nums; }
.flow-sub { grid-area: sub; font-size: 11px; color: var(--ink-muted); }

/* A COLOUR PER BLOCK, declared once as a custom property.
   
   Every other rule - the icon, the border, the tint, and the wires arriving at
   this block - reads `--flow-colour`, so a component's colour is stated in one
   place. Setting it four times over would be four places to disagree.
   
   The series palette the power chart already uses, so the diagram and the chart
   under it never disagree about which colour means solar. The battery used to
   take `--good` and the grid `--ink-muted`, which meant the battery box and the
   battery line were different colours and the grid had no colour at all. */
.flow-pv      { --flow-colour: var(--series-1); }
.flow-load    { --flow-colour: var(--series-2); }
.flow-grid    { --flow-colour: var(--series-3); }
.flow-battery { --flow-colour: var(--series-4); }

.flow-node .flow-icon { color: var(--flow-colour, var(--ink-muted)); }

/* Tinted rather than filled. The numbers are the point of these boxes and they
   have to stay as readable as any other figure on the page, so the colour lands
   on the border and as a wash behind it - enough to name the component at a
   glance, not enough to fight the text. `color-mix` against `--surface` keeps it
   honest in both themes without a second palette for dark. */
.flow-node {
  border-color: color-mix(in srgb, var(--flow-colour, var(--border)) 45%, var(--border));
  background: color-mix(in srgb, var(--flow-colour, var(--surface)) 8%, var(--surface));
}

/* The label takes the colour; the value does not. "Solar" in solar green is a
   heading doing a legend's job, where a coloured NUMBER reads as a status - and
   these numbers are already the thing the eye goes to. */
.flow-node .flow-label { color: color-mix(in srgb, var(--flow-colour, var(--ink-muted)) 70%, var(--ink-muted)); }

.flow-links {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  justify-items: center;
  color: var(--border);
  font-size: 15px;
  line-height: 1.1;
}
.flow-centre + .flow-links, .flow-links:last-of-type { grid-template-columns: 1fr; }
/* A leg carrying power is drawn in the ink colour; an idle one stays faint, so
   "nothing is flowing here" is visible without a number being read. */
.flow-link.flow-live { color: var(--ink); }

/* The arrow glyphs are hidden once the wires carry direction.
   
   With six lines meeting at the middle, the two horizontal arrows sit either
   side of the junction dot and turn the centre into "→ ⊙ →" - three marks in
   ten pixels, none of which says anything the travelling dashes do not.
   
   Hidden VISUALLY, not removed: each still carries a title naming the direction
   in the words an operator uses ("importing from the grid"), and the boxes below
   say "importing" and "charging" in text. Deleting them would take that with it.
   
   The standard visually-hidden pattern, the same one the collapsed navigation
   uses for its labels. */
.flow-link {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* Except when motion is off, where the dashes are the only thing that was
   carrying direction. Then the arrows come back and do the job instead - the
   diagram degrades to what it was rather than to something quieter and less
   informative. */
@media (prefers-reduced-motion: reduce) {
  .flow-link {
    position: static;
    width: auto;
    height: auto;
    overflow: visible;
    clip-path: none;
  }
}

/* --------------------------------------------------------------------------
   The wires: curved connectors between the four boxes and the junction.

   Drawn by `mountFlowWires()` in station-view.js, which measures the real boxes
   and writes the paths - only the paint lives here.

   BEHIND the boxes, and not interactive. The nodes carry the numbers and must
   stay readable over a wire passing under them; `pointer-events: none` keeps
   the overlay from swallowing a click meant for the card behind it.
-------------------------------------------------------------------------- */
.flow-wires {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: visible;
  pointer-events: none;
  z-index: 0;
}

/* The boxes and the junction sit above the wires. Without this the overlay
   paints over the numbers, which is the one thing the diagram cannot afford. */
.flow-cell { position: relative; z-index: 1; }

.flow-wire {
  fill: none;
  stroke: var(--border);
  /* Six lines rather than four legs, so each is a little finer - at 2.5 the
     perimeter curves and the two straight diagonals visibly crowd each other
     where they meet a box. */
  stroke-width: 2;
  stroke-linecap: round;
  /* A dash pattern is the flow indicator: short marks with a long gap read as
     discrete packets travelling, where a moving solid line reads as nothing at
     all. The animation moves the pattern along the path, so the marks travel. */
  stroke-dasharray: 7 13;
  animation: flow-travel 1.4s linear infinite;
}

/* No reverse variant: `drawWires()` draws every live path FROM the supplying box,
   so the dashes already travel the right way. One definition of "a dash moves one
   pattern length" cannot drift out of step with its own opposite when there is no
   opposite. */

/* Nothing is flowing between this pair - two sources, two sinks, or a component
   sitting idle. Kept visible and still, because an absent line would say the two
   boxes are unconnected, which is a different fact (LESSONS 27). Thinner and
   fainter so six lines do not read as six flows. */
.flow-wire[data-dir="idle"] {
  animation: none;
  stroke-width: 1.5;
  stroke-dasharray: 2 6;
  stroke: var(--border);
  opacity: 0.55;
}

@keyframes flow-travel {
  to { stroke-dashoffset: -20; }   /* one whole 7+13 pattern */
}

/* The colour is the DESTINATION's, decided in station-view.js: a line is drawn in
   the colour of the box it is going to, so a box and everything arriving at it
   read as one group. The same tokens the blocks above take, so a line and its
   destination cannot drift apart. */
.flow-wire-pv      { stroke: var(--series-1); }
.flow-wire-load    { stroke: var(--series-2); }
.flow-wire-grid    { stroke: var(--series-3); }
.flow-wire-battery { stroke: var(--series-4); }
/* Nothing is flowing along this line, so there is no destination to take a
   colour from. Drawn neutral rather than guessed. */
.flow-wire-none    { stroke: var(--border); }

/* Motion is the whole point of this component, so it is not simply switched off:
   the dashes stop travelling and the pattern stays, which still distinguishes a
   live leg from an idle one by density alone. */
@media (prefers-reduced-motion: reduce) {
  .flow-wire { animation: none; }
}

/* NOTE ON ORDER: the mobile block below sits AFTER every rule it overrides, and
   has to. A media query adds no specificity, so when it was placed further up
   the file - where the old single-column fallback had been - `.flow-node`'s
   28px icon column simply won, the boxes never shrank, and they overflowed the
   card on a phone while the media query looked correct in isolation. */
/* --------------------------------------------------------------------------
   MOBILE KEEPS THE CROSS. It gets smaller; it does not restack.

   It used to collapse to one column at 560px, which was right when the diagram
   was four legs meeting a junction. It is wrong now: the six lines only mean
   anything in two dimensions. Stacked, three of them run behind the boxes in
   between and the picture reads as a chain - the Solar-Battery line hidden
   behind Load and Grid says nothing at all.

   Measured on the real page rather than assumed, because the sidebar becomes a
   rail on mobile instead of hiding, so it keeps taking width:

     viewport 320px -> card 232px      390px -> card 302px
     viewport 360px -> card 272px      480px -> card 392px

   So the cross has to fit about 230px in the worst case: two boxes, the 10px
   middle column and two gaps. That is what the sizes below are for.
-------------------------------------------------------------------------- */
@media (max-width: 640px) {
  /* A 2x2 rather than a cross, because a phone has height and not width.
     
     Solar and Load on top, Grid and Battery below. The six lines are unchanged
     and so is their classification: the two DIAGONALS are Solar-Battery and
     Grid-Load, which are exactly the two pairs already drawn straight, and the
     four edges are the ones already drawn curved. The geometry code needs no
     mobile branch at all - it measures the boxes wherever they are.
     
     The cross wastes a phone's width: two side boxes plus a middle column leaves
     each box filling its column and the horizontal lines reduced to a six-pixel
     sliver. Here each box gets half the width and the diagonals get real length. */
  .flow-cross {
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    grid-template-areas:
      "pv   load"
      "grid batt";
    gap: 30px 14px;
  }
  .flow-cell-pv, .flow-cell-grid, .flow-cell-load, .flow-cell-batt { justify-self: center; }
  .flow-cell-pv, .flow-cell-batt { flex-direction: column; }

  .flow-node {
    padding: 6px 7px;
    grid-template-columns: 18px 1fr;
    gap: 0 6px;
    border-radius: 8px;
  }
  .flow-icon svg { width: 14px; height: 14px; }
  .flow-label { font-size: 10px; }
  .flow-value { font-size: 13px; }
  .flow-sub   { font-size: 9px; }

  /* Thinner lines for a smaller picture. The DASH PATTERN is deliberately not
     touched: `flow-travel` moves the offset by exactly one 7+13 pattern, so
     changing the pattern here without changing the keyframe would make the
     dashes jump at the end of every cycle. If it ever needs to change, both
     move together. */
  .flow-wire { stroke-width: 1.6; }
  .flow-wire[data-dir="idle"] { stroke-width: 1.1; }
}

/* The smallest phones, where the card is about 230px. */
@media (max-width: 380px) {
  .flow-cross { gap: 24px 10px; }
  .flow-node { padding: 5px 6px; grid-template-columns: 16px 1fr; gap: 0 5px; }
  .flow-icon svg { width: 13px; height: 13px; }
  .flow-value { font-size: 12px; }
  /* The capacity line is the one thing here that is context rather than a
     reading - "100.00 kW installed" beside a live figure - so it is what goes
     when there is no room for both. The reading never goes. */
  .flow-sub { display: none; }
}

.ov-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(120px, 100%), 1fr));
  gap: 10px;
}
.ov-money { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); }

.ov-figure {
  padding: 8px 10px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--surface);
}
/* A withheld figure is visibly withheld rather than looking like a zero somebody
   forgot to fill in. The reason is on the element's title and spelled out below. */
.ov-missing { border-style: dashed; }
.ov-missing .ov-value { color: var(--ink-muted); }

.ov-label { font-size: 11px; color: var(--ink-muted); }
.ov-value { font-size: 18px; font-weight: 600; font-variant-numeric: tabular-nums; }
.ov-sub { font-size: 11px; color: var(--ink-muted); }

.ov-note { margin: 10px 0 0; }
.ov-why {
  margin: 8px 0 0;
  padding-left: 18px;
  font-size: 12px;
  color: var(--ink-muted);
}
.ov-why li { margin-bottom: 3px; }

/* The way through from a grouping's card to its combined view. Set apart from
   the constituent links under it, which go somewhere quite different. */
.fleet-child-open {
  border-left-color: var(--ink);
  font-weight: 600;
  margin-bottom: 3px;
}

/* A flagged-but-kept figure on the Station page reads as a warning, not as an
   explanation - it is a value we do not believe rather than one we lack. */
.ov-suspect li { color: var(--warning); }

/* The Station page's section menu (hierarchy addendum §10). Deliberately the
   same shape as the top navigation's tabs - §10 asks for the platform's existing
   visual style, since this is a structure change and not a redesign. */
.station-tabs {
  display: flex;
  flex-wrap: wrap;
  gap: 4px;
  margin-bottom: 16px;

  /* RESERVED, the last of the four things above the fold that were not - T-224.
   *
   * `station-menu.js` builds these tabs from the page's own sections after the
   * payload lands, into a nav that is empty in the markup. With `.site-head`
   * reserved the page still moved 39px, and this was all of it.
   *
   * Measured over 8 Stations x 5 widths: 40px at every width from 760 up, 83 at
   * 420 where the tabs wrap, and never empty or hidden in any of the 40 samples.
   * 40 is therefore the floor, and on the same terms as everything else in this
   * file - the smallest anybody needs, so it cannot open a gap, and the phone's
   * residual is left alone rather than closed by raising it.
   *
   * Unlike `.site-head` this one does not vary by account: the sections a Station
   * has are the same whoever is looking. */
  min-height: 40px;
  border-bottom: 1px solid var(--border);
}
.station-tab {
  padding: 8px 14px;
  border: 0;
  border-bottom: 2px solid transparent;
  border-radius: 0;
  background: none;
  color: var(--ink-muted);
  font: inherit;
  font-size: 14px;
  cursor: pointer;
}
.station-tab:hover { color: var(--ink); }
.station-tab.is-current {
  color: var(--ink);
  font-weight: 600;
  border-bottom-color: var(--ink);
}

/* Only the current group is on screen. Done here rather than by toggling
   `hidden` in JavaScript so that an element the page has hidden for its own
   reasons - a notice banner, a permission-gated control - stays hidden when its
   group is shown, instead of being forced visible. */
/* No blanket "hide everything" any more - the per-menu rules below hide exactly
   what does not belong, so nothing has to be re-shown. `data-station-menu="always"`
   (the page header, and any banner about the page as a whole) is excluded there
   rather than re-shown here; hiding those behind a menu once hid each site's own
   name. */
/* One line per menu, and it has to be one line per menu: CSS cannot match an
   attribute against an ancestor's, so this list cannot be derived from MENUS in
   station-menu.js the way everything else here is derived. A menu added there
   and forgotten here has a tab that reveals nothing - which is exactly what
   happened when Alarms was added, and why `station-menu.mjs` now clicks every
   tab and asserts something appears. */
/* Written as "hide what does not belong" rather than "hide everything, then
   show the match".

   The previous form re-showed the current group with `display: revert`, and
   `revert` rolls the property back to the USER-AGENT default - `block` for a
   div. So every section this menu showed lost whatever display the stylesheet
   gave it, and `.site-cols { display: grid }` became `display: block`: §9's
   two-column layout, diagram left and Overview right, silently flattened into
   one column the day the menu was added. Nothing errored. The page simply looked
   stacked, which reads as a design choice rather than as a fault - the same
   shape as the CSP discarding inline styles (LESSONS 24).

   Only ever hiding avoids the whole class of problem: a shown element is not
   targeted at all, so it keeps its own display, and anything the page hid for
   its own reasons stays hidden because nothing re-shows it. */
main[data-station-menu-shown="dashboard"] > [data-station-menu]:not([data-station-menu="dashboard"]):not([data-station-menu="always"]),
main[data-station-menu-shown="devices"]   > [data-station-menu]:not([data-station-menu="devices"]):not([data-station-menu="always"]),
main[data-station-menu-shown="alarms"]    > [data-station-menu]:not([data-station-menu="alarms"]):not([data-station-menu="always"]),
main[data-station-menu-shown="om"]        > [data-station-menu]:not([data-station-menu="om"]):not([data-station-menu="always"]),
main[data-station-menu-shown="settings"]  > [data-station-menu]:not([data-station-menu="settings"]):not([data-station-menu="always"]) { display: none; }
/* `hidden` must still win over the rule above, which it does not by default once
   an explicit `display` is set. */
main[data-station-menu-shown] > [data-station-menu][hidden] { display: none; }

/* ---------------------------------------------------------------------------
   Energy balance (hierarchy addendum §9): one card, two views
--------------------------------------------------------------------------- */

/* CAPPED IN WIDTH, FILLING IN HEIGHT — and the two are separate decisions.
 *
 * The width cap stays and the reason it was written still holds: a ring and a
 * two-column legend stretched across a 1,400px card put the percentages a hand's
 * width from their labels, and the SVG's own text scales with the box, so a
 * full-width donut renders its centre figure at about 50px.
 *
 * What changed on 2026-08-20 is the HEIGHT. This card is now matched to the
 * Combined power curve beside it — 627px at 4K — and a 170px ring sat in the top
 * corner of it. So the block centres itself in whatever height it is given and the
 * ring scales with the space, which is what "fill the black better" asks for
 * without reopening the width problem.
 *
 * The cap moved 540 -> 820px rather than being removed: at 820 the legend's
 * values are still beside their labels, and the ring's own ceiling keeps its
 * centre figure in the mid-twenties rather than the fifties. */
.eb-consumption {
  display: flex; flex-wrap: wrap; align-items: center; gap: 28px;
  max-width: 820px;
  /* Fills and centres in the card's spare height. `100%` rather than a min-height
     so it never forces the card taller than the chart it is matched to. */
  /* Fills and centres in the card's spare height. A flex ITEM here when the card
     is matched (see the `#eb-body` rule below), so it grows and shrinks with the
     box rather than asserting a percentage of it. */
  flex: 1 1 auto;
  min-height: 0;
  justify-content: center;
  /* `start`, NOT `center`, and this is the whole of Rainer's "the energy circle
     on load started at one location and then moved into position".
   *
   * Measured 2026-08-22: the card's TOP never moves. Its HEIGHT grows — 444 to
   * 490 to 510px — as `card-match` follows the chart beside it and that chart's
   * content lands. Centring the ring in that height turns the card's growth into
   * MOVEMENT of the ring: it slides down by half of every pixel the card gains,
   * 85px at 1600px wide.
   *
   * Anchoring the line to the top makes the ring's position a function of the
   * card's top edge, which is fixed, instead of its height, which is still
   * arriving. The ring is then simply where it is drawn.
   *
   * This was first attempted by DELAYING the ring's fill until the card stopped
   * moving, and that is why it is written down: the delay failed one run in
   * three, because how long the card takes to settle depends on when a sibling
   * panel's request comes back — 224ms on one load and 718ms on the next. A
   * timing fix for a layout problem is a race you win most of the time. The fill
   * animation is still there and still waits, but it is now polish rather than a
   * mask, and `tests/ui/eb-donut.mjs` asserts the ring does not move under it.
   *
   * CENTRED AGAIN SINCE 2026-08-22, on Rainer's instruction: *"Energy balance needs
   * to be locked in the centre before page load as its moved to the top."* Both
   * halves of that are load-bearing — centred, and not moving — and the reason it
   * is now safe is the paragraph above rather than anything here: the fill waits
   * for the card to stop moving before it begins, so by the time the ring is
   * visible the height it is centred in has settled. Measured across four runs on
   * both tiers: the ring sits at a constant 134px (grouping) and 129px (System)
   * from its card's top, and `eb-donut.mjs` fails if that ever varies again.
   *
   * If the fill's settle-wait is ever removed, this has to go back to `start`. The
   * two are one decision. */
  align-content: center;
  margin-inline: auto;
}

/* Scales with the space, with both ends pinned — and the space is the CARD, not
   the viewport.
 *
 * The ceiling was `34vh`, which is a measure of the screen and not of the box the
 * ring sits in. At 1920x1080 that resolved to 300px inside a 538px-wide card, so
 * the ring plus its legend exceeded one row, the legend wrapped underneath, and
 * the card stood 476px against the 416px chart it is matched to. Bigger on a
 * shorter screen, which is the opposite of the intent.
 *
 * `45%` of the container is the term that fixes it: enough room is always left
 * beside the ring for the legend, so it never wraps and the card never grows a
 * second row. `34vh` is kept as a third bound so a very tall narrow window does
 * not get a ring taller than the card either.
 *
 * The 300px ceiling is arithmetic rather than taste: the viewBox is 180 units
 * across and the centre figure is 17 of them, so 300px renders that text near
 * 28px — a hero number. Past that it reads as a mistake. */
.eb-donut {
  width: max(150px, min(300px, 45%, 34vh));
  height: auto;
  aspect-ratio: 1;
  flex: 0 0 auto;
}
/* THE RING MOVES TO NEW FIGURES RATHER THAN JUMPING TO THEM.
 *
 * Rainer, 2026-08-22: the bars animate on first load only, and "on refresh where
 * numbers change while we on the page it needs to smoothly move to the new
 * values." `updateRing()` writes the new dash as an ATTRIBUTE — `style=` is
 * blocked by `style-src 'self'` — and a presentation attribute feeds the cascade,
 * so the computed value changes and this transition carries it.
 *
 * It only works because `startFill()` REMOVES the sweep once it has run: a frozen
 * SMIL animation keeps overriding the attribute, so without that the dash would
 * change and nothing would move.
 *
 * Slower than the sweep on purpose. Arriving is an event and deserves pace; a
 * figure ticking over while somebody reads it should not pull the eye off whatever
 * they were looking at. Hover stays fast — that one is a response to the pointer,
 * and 800ms of it would feel broken. */
.eb-slice { transition: opacity .12s; }

/* ONLY ONCE THE SWEEP IS OVER, and this is not a refinement - without the
   `.eb-live` gate the two mechanisms fight and the sweep is the one that loses.
 *
 * Rainer, 2026-08-22: "its not smooth yet as the next line color starts before the
 * first is done." Measured: the SMIL timing was exactly right - slice 1 began at
 * 823ms, which is the millisecond slice 0's animation ended - but each slice went
 * on moving for another 750ms after its turn. A CSS transition on a property SMIL
 * is animating interpolates towards every intermediate value SMIL sets, so an
 * 800ms transition smears each frame of the sweep across the next 800ms. Slice 0
 * took 983ms to draw a 229ms arc, slice 1 started while it still had 750ms to run,
 * and the overlap is what he was looking at.
 *
 * `eb-live` is added when the sweep is retired (and immediately on a draw that
 * never animates), so the two never hold the property at the same time. */
.eb-donut.eb-live .eb-slice {
  transition: opacity .12s,
              stroke-dasharray .8s cubic-bezier(.25, .1, .25, 1),
              stroke-dashoffset .8s cubic-bezier(.25, .1, .25, 1);
}
.eb-slice:hover { opacity: .82; }

/* The ring's empty track, drawn under the slices.
 *
 * It exists for the fill animation: with the slices starting at zero length there
 * would otherwise be nothing on screen for the first quarter second, and a card
 * that is blank and then suddenly a ring is the flash Rainer reported. The track
 * puts the shape on the page immediately and lets the COLOUR be the thing that
 * arrives. It is also why the animation can be delayed past the card-match
 * reflow without the card looking broken while it waits. */
.eb-track { stroke: var(--border); opacity: .5; }

/* The centre figure, hidden only while the ring waits for the card to stop
   moving, and faded in when the fill is released. The class is added by the
   renderer ONLY when it is animating, so a card drawn without animation never
   carries it and cannot be left invisible by a script that failed halfway. */
.eb-centre-group { transition: opacity .45s ease; }
.eb-centre-group.eb-hidden { opacity: 0; }

/* NO `font-size` HERE, deliberately, and it is not an oversight.
 *
 * The centre figure is sized by `fitCentre()` in energy-balance.js, which
 * measures the text the browser actually drew and shrinks it to the ring's hole.
 * It writes a `font-size` ATTRIBUTE, and a CSS declaration beats a presentation
 * attribute — so a `font-size` in this rule would silently defeat the fit and the
 * number would go back to overflowing. The default lives in `CENTRE_SIZE` beside
 * the code that adjusts it.
 *
 * What was wrong: "315.97 MWh" measured 119.17 units inside a 94-unit hole and
 * ran across the coloured band on both sides. The unit moved down to the
 * sub-line, which is a label rather than a figure, and the number was left the
 * centre to itself. */
.eb-centre {
  text-anchor: middle; fill: var(--ink);
  font-weight: 600; font-variant-numeric: tabular-nums;
}
/* `font-size` omitted for the same reason as `.eb-centre` above: fitCentre()
   writes it as an attribute, and a declaration here would defeat it. */
.eb-centre-sub { text-anchor: middle; fill: var(--ink-muted); }

/* Whoever prefers less motion gets none of it. The ring is still drawn — the
   final state is the same picture — it simply arrives whole. Belt and braces
   with the check in mountEnergyBalance(), which omits the <animate> elements
   entirely: this rule catches an animation added later and not routed through
   that check. */
@media (prefers-reduced-motion: reduce) {
  .eb-donut * { animation: none !important; transition: none !important; }
  /* THE SANKEY TOO, and it needs the group itself rather than only its children:
     the build-up animates `.eb-flow-body`, so a rule scoped to descendants would
     leave the one animation that moves the whole diagram running. The travelling
     lines are children and are caught either way.

     `clip-path: none` because switching an animation off mid-definition leaves the
     element at its `from` value - which here is clipped to nothing, so the flow
     would be invisible rather than still. A reduced-motion reader gets the whole
     diagram at once, which is the point. */
  .eb-sankey *, .eb-sankey { animation: none !important; transition: none !important; }
  .eb-flow-body { clip-path: none !important; }
}

/* Grown with the ring: 13 -> 14.5px, and the rows given real padding so the
   legend reads as a column of data rather than three lines squeezed beside a
   graphic. `min-width: 0` because a grid item's default refuses to shrink and a
   long label would push the values off the card. */
.eb-legend {
  flex: 1 1 220px;
  min-width: 0;
  margin: 0; padding: 0; list-style: none;
  font-size: 14.5px;
}
.eb-legend li {
  display: grid;
  grid-template-columns: 14px minmax(0, 1fr) auto auto;
  align-items: center;
  gap: 10px;
  padding: 7px 0;
  /* A hairline between rows, so three figures in a tall card read as a table
     rather than as floating text. Not on the last, which would look like a rule
     under nothing. */
  border-bottom: 1px solid var(--border);
}
.eb-legend li:last-child { border-bottom: 0; }
.eb-swatch { width: 14px; height: 14px; border-radius: 3px; }
.eb-legend-value, .eb-legend-pct { font-variant-numeric: tabular-nums; }
.eb-legend-value { font-weight: 500; }
.eb-legend-pct { color: var(--ink-muted); min-width: 40px; text-align: right; }

/* THE BODY OF A MATCHED CARD IS A FLEX COLUMN, so its contents can shrink.
 *
 * The Energy flow view is TWO elements — the Sankey and a note under it — and
 * that is why `max-height: 100%` on the SVG alone was not enough: at 2560px it
 * clamped the drawing and the note still pushed the card to 543px against the
 * chart's 459px. A percentage ceiling on one child cannot account for a sibling.
 *
 * As a flex column of definite height, the chart takes `flex: 1 1 auto` with
 * `min-height: 0` and gives way to the note instead of overflowing. `min-height:
 * 0` is the load-bearing half: a flex item's default `min-height: auto` refuses
 * to shrink below its content, which is the whole failure.
 *
 * Scoped to `#eb-body` rather than to every matched card's last child, because
 * the Overview is matched too and turning its body into a flex column would
 * change a layout that is already right.
 */
.card-match > #eb-body {
  display: flex;
  flex-direction: column;
  gap: 10px;
  min-height: 0;
}
/* `flex: 1 1 0` and not `1 1 auto`, which is the difference between working and
   not: with an `auto` basis the item's height starts from the SVG's own intrinsic
   ratio against its width, and shrinking the box recomputes that height from the
   width again — so it never settles below what the aspect ratio wants. A zero
   basis makes the height come from the COLUMN instead, and the viewBox letterboxes
   inside it. At 2560px the auto version still stood 553px against a 459px chart. */
.card-match > #eb-body > .eb-sankey { flex: 1 1 0; min-height: 0; height: auto; }

/* THE EMPTY STATE, CENTRED IN THE HEIGHT IT CANNOT FILL - T-228.
 *
 * The card is matched to the Power curve beside it, so on a Station whose data
 * cannot fill it the message had several hundred pixels of nothing beneath it.
 * Shrinking the card is the wrong fix and stays rejected: it makes block 2
 * ragged on exactly the Stations already worst off, which is the complaint
 * T-219 fixed and which has been filed three times.
 *
 * `flex: 1 1 0` for the same reason the Sankey above uses it rather than
 * `1 1 auto` - the height has to come from the COLUMN, not from the content's
 * own idea of itself. Then an inner column centres the text in whatever that
 * turns out to be.
 *
 * Scoped to this card. `.empty` is used elsewhere - a bare list, an empty table
 * - where it is a short block at the top of its own box and centring it in the
 * parent's full height would be wrong.
 */
.card-match > #eb-body > .empty {
  flex: 1 1 0;
  min-height: 0;
  display: flex;
  flex-direction: column;
  justify-content: center;
}

/* The other view in the same card, and it needs a CEILING as well as a cap.
 *
 * `align-self: stretch` on the card sets a floor, not a ceiling: content taller
 * than the row still wins and the row grows with it. So when the width cap went
 * 540 -> 820px, the Sankey's aspect ratio made it taller than the 627px chart it
 * is matched to and the card outgrew its neighbour — reported as "the energy flow
 * is now bigger than the combined power curve, but the consumption is correct".
 * The ring was fine because it is a square with its own 300px ceiling.
 *
 * `max-height: 100%` is the ceiling. The SVG carries a viewBox and the default
 * `preserveAspectRatio`, so it letterboxes inside whatever box it is given rather
 * than distorting — the drawing simply gets smaller until it fits.
 *
 * `height: auto` and not `100%`: a percentage height against a parent of
 * indefinite height resolves to nothing, and this same card is used where nothing
 * is matching it. There, `max-height` quietly does not apply and the view keeps
 * the behaviour it had. */
.eb-sankey {
  width: 100%;
  max-width: 820px;
  height: auto;
  max-height: 100%;
  display: block;
  margin-inline: auto;
}
/* Translucent, so crossing ribbons read as crossing rather than as one covering
   the other. */
.eb-ribbon { opacity: .38; }
.eb-ribbon:hover { opacity: .62; }

/* THE FLOW BUILDS UP, LEFT TO RIGHT - Rainer, 2026-08-22: "the Energy flow also
   needs to build up".
 
   Clipped on the GROUP, not on each ribbon: `inset()` resolves against the
   element's own box, so per-ribbon this would reveal every ribbon across its own
   width simultaneously - four little sweeps at once instead of one edge crossing
   the diagram. On the group the box is the whole thing.
 
   `forwards` so it holds the revealed state; without it the clip returns to its
   `from` and the diagram empties again, which is the same trap SMIL's `fill="freeze"`
   exists for. Slightly longer than the power curve's 850ms because the shape is
   wider and the eye follows the edge further. */
.eb-flow-body.eb-building { animation: eb-build .95s cubic-bezier(.22, 1, .36, 1) forwards; }

@keyframes eb-build {
  from { clip-path: inset(0 100% 0 0); }
  to   { clip-path: inset(0 0 0 0); }
}

/* AND THEN THE WHOLE BAR KEEPS MOVING - "instead of the - lines can we make the
   whole bar flow."
 
   Nothing to declare here: the motion is a travelling gradient on the ribbon's own
   fill, defined in `energy-balance.js` because a gradient's stops are per-colour
   and cannot come from a static stylesheet. The first attempt was a dashed line
   down the middle of each ribbon, borrowed from `.flow-wire` - right idiom for a
   WIRE, wrong for a BAND, where the whole width is the quantity and a thin dash
   reads as a separate thing lying on top of it.
 
   The ribbons keep their own opacity below; the gradient carries the light-dark
   banding within that. */
.eb-node-label { fill: var(--ink); font-size: 11px; }
.eb-node-label.eb-right { text-anchor: end; }
.eb-note { margin-top: 8px; }

/* The window the ring's figures cover - T-166. Above the diagram rather than
   below it, because it is the subject of what follows and not a footnote to it:
   "29.6 kWh of WHAT period" is the question a reader has before they look at the
   ring, and it is the question Reinier's report came down to. The default
   paragraph margin would put 12px of air between the card head and the line and
   another 12 below it, which reads as a gap rather than as a label. */
.eb-period { margin: 0 0 8px; }

/* One class per series rather than an inline style: the pages run under
   `style-src 'self'`, which blocks a style attribute outright. SVG fill and
   stroke are attributes and are unaffected. */
.eb-swatch-pv      { background: var(--series-1); }
.eb-swatch-battery { background: var(--series-2); }
/* The two battery origins - T-306. Same hue as the battery it subdivides, so the
   ring reads as one region split rather than two unrelated sources. */
.eb-swatch-battery_pv   { background: var(--series-2); }
.eb-swatch-battery_grid { background: var(--series-2b); }
.eb-swatch-grid    { background: var(--series-3); }
.eb-swatch-export  { background: var(--series-4); }
.eb-swatch-charge  { background: var(--series-2); }

/* §9's graph tabs, in the platform's existing tab style. */
.graph-tabs { display: flex; gap: 2px; margin-left: 10px; }
.graph-tab {
  padding: 3px 10px; border: 1px solid transparent; border-radius: 6px;
  background: none; color: var(--ink-muted); font: inherit; font-size: 12px; cursor: pointer;
}
.graph-tab:hover { color: var(--ink); }
.graph-tab.is-current { color: var(--ink); background: var(--surface-2); border-color: var(--border); }

/* The bar chart is wide and fills the card — but BOUNDED, or it grows with the
   width and overshoots the block: at 2,300px it stood 744px against the curve's
   632px. Same viewBox letterboxing as the heatmap below. */
/* NO `flex` here, and that is the whole point of this note.
 *
 * `flex: 1 1 0` was tried and collapsed both charts to ZERO HEIGHT — the harness
 * reported the heatmap as 2300x0 and still passed, because the fill check only
 * looked at width. A zero basis takes the height from the flex column, and this
 * card is a plain block: `#graph-body` has no definite height to give, so the
 * charts got nothing. `flex` belongs where a definite height exists, which is the
 * Energy-balance card, not here.
 *
 * `height: auto` with a `max-height` is what works in a block: the SVG keeps its
 * own ratio from the width, and the ceiling clamps it — the viewBox letterboxes
 * inside that box rather than distorting. */
/* THE STAGE. Its height is the CURVE's height, so all three tabs draw into the
   same box and switching between them moves nothing.
   
   This is also what makes `flex` usable inside it. The comment above records
   `flex: 1 1 0` collapsing both charts to zero, and the diagnosis was right: a
   zero basis takes its height from the column, and the column had none. It has
   one now — a definite height, from the curve — so the charts below can fill it
   and their captions can keep their natural size. */
/* THE STAGE: the curve's height, PLUS a strip for the caption the other two tabs
   carry beneath their chart.
   
   Rainer asked for two things at once - "all be the height of power" and "the same
   fixed location" - and with the caption inside the box only one of them was
   possible. The chart filled the stage less its own caption, so Energy drew at
   226px and the Heatmap at 196px against the curve's 260px: same block, different
   graphs. Moving the captions out instead made every graph the same height and let
   the CARD change size between tabs, which is the other complaint.
   
   So the box is `curve + strip` on every tab. The chart is exactly the curve's
   height on all three, the caption lives in the strip, and on the curve tab the
   strip is simply empty. `min-height` rather than `height` so a caption that runs
   longer than the strip at a narrow width pushes the box out instead of being
   clipped - it degrades by growing, which is visible, rather than by hiding text. */
.graph-stage {
  min-height: calc(var(--graph-inner-h, 260px) + 3.4em);
  display: flex;
  flex-direction: column;
}

.graph-stage > #chart-power:not([hidden]) { height: var(--graph-inner-h, 260px); flex: none; }

.g-chart {
  width: 100%;
  display: block;
  /* EXACTLY the curve's height, not what the caption leaves. `flex: 1 1 0` was
     the previous attempt and it made each graph the stage less its own caption -
     which is how Energy came out 34px shorter than the curve and the Heatmap
     64px shorter. The caption has its own strip now. */
  height: var(--graph-inner-h, 260px);
  flex: none;
}

/* THE THREE TABS ARE ONE BLOCK. `--graph-h` is published by station.js from the
   curve's own rendered height, so Energy and Heatmap occupy exactly what the
   curve does and switching tabs no longer resizes the card — nor the Energy
   balance matched beside it. The fallback is the curve's own floor.
   
   A flex column so the chart inside can be told to fill and the caption under it
   keeps its natural height. */
/* The CARD carries the floor, not the body: the card's own height is the one
   figure that already includes its header, its tab row, the legend that shows on
   one tab only, and every margin between them — so matching it needs no
   arithmetic about which of those are in play. Summing the parts was tried twice
   and left the tabs 8px apart at 4K and 46px at 1080p. */
.card:has(> .chart-head #graph-tabs) { min-height: var(--graph-card-h, 0px); }

/* The header keeps its tallest state's height on every tab, so a control that
   belongs to one graph cannot move the other two. See publishGraphHeight().
   
   `align-items: center` rather than `.chart-head`'s usual `baseline`, and this is
   the last 6px of "locked in place". A reserved height stops the header changing
   size; it does not stop the tab row MOVING inside it, because baseline alignment
   positions each item against the tallest one on the line - and the curve tab
   shows a select that the other two do not. Measured on the System page: the tabs
   sat at y=951 with the curve's controls present and y=945 without them.
   Centring in a header of fixed height leaves nothing for a sibling to shift. */
.card > .chart-head:has(#graph-tabs) { min-height: var(--graph-head-h, 0px); }

/* A curve-only control, kept IN FLOW on the other two tabs.
   
   `align-items: center` on the header was tried instead and made things worse: it
   fixed the System page's 6px and moved the Site/Plant page's tabs 23px, because
   that header wraps to two lines at 1600px and a centred single line then sits
   lower than a filled two. Measured, both pages, which is the only reason it was
   caught.
   
   Reserving the space keeps the header's LINE STRUCTURE identical on every tab, so
   baseline alignment - which is right everywhere else on the page - has nothing to
   shift against. `visibility` also takes these out of the tab order while they are
   inert, which `opacity` would not. */
.chart-head .is-reserved { visibility: hidden; }

/* The graph plus its caption. No `height: 100%` any more: the chart carries the
   curve's height itself and the caption takes what it needs from the strip. */
#graph-body:not([hidden]) { display: flex; flex-direction: column; gap: 8px; }

/* The legend is the curve's alone, but it is KEPT IN FLOW on the other two tabs.
   Hidden outright, its height vanished and the whole stage slid up by it — the
   "fixed location" half of Rainer's request. Invisible-but-present costs the same
   space on every tab, so nothing moves. It stays populated while hidden, so its
   height is the height it will have when it returns. */
#power-legend.is-reserved { visibility: hidden; }

/* THE CURVE'S NOTE, kept in flow on the other two tabs for the same reason as the
   legend above and as the header's controls: this card is height-matched by the
   Energy balance card beside it, so a card that changes size between tabs moves a
   card the reader was not even looking at. Measured at 1600px: `hidden` here left
   the card 521px on the curve and 520px on the other two. The `.chart-head` rule
   cannot reach it - the note sits BELOW the stage, not in the header row. */
#power-note.is-reserved { visibility: hidden; }

/* THE HEATMAP FILLS THE BLOCK.
 *
 * It used to keep its intrinsic size — `width: auto` — and the reason was sound:
 * stretched to the card width, three days of hours became a nine-thousand-pixel
 * column, because the height followed the width. But a month of hours is about
 * 724 x 232, which in a 2,169px card is a small tile in a large empty box.
 *
 * So it fills the width AND is bounded in height. The SVG carries a viewBox, so
 * `preserveAspectRatio` letterboxes it inside that box rather than distorting:
 * three days can no more become a nine-thousand-pixel column than a month can. */
.g-heatmap {
  width: 100%;
  /* Same as `.g-chart`: exactly the curve's height. The viewBox and
     `preserveAspectRatio` still letterbox it inside that box, so three days can
     no more become a nine-thousand-pixel column than a month can. */
  height: var(--graph-inner-h, 260px);
  flex: none;
}
.g-bar-pv { fill: var(--series-1); }
.g-bar-grid { fill: var(--series-3); }
.g-baseline { stroke: var(--border); stroke-width: 1; }
.g-axis { fill: var(--ink-muted); font-size: 10px; }
.g-cell { fill: var(--series-1); }
.g-legend { display: flex; gap: 16px; margin: 8px 0 0; padding: 0; list-style: none; font-size: 12px; }
.g-legend li { display: flex; align-items: center; gap: 6px; }
.g-caption { margin-top: 8px; }

/* The weather card (§9), top-left of the flow diagram. Deliberately compact: it
   is context for the figures beside it, not a figure of its own. */
.wx {
  margin-bottom: 12px;
  padding: 8px 10px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--surface-2);
}
.wx-head { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
.wx-label { font-size: 11px; color: var(--ink-muted); text-transform: uppercase; letter-spacing: .04em; }
.wx-figures { display: flex; flex-wrap: wrap; gap: 14px; }
.wx-figure { font-size: 16px; font-weight: 600; font-variant-numeric: tabular-nums; }
/* Each figure says what it IS (Addendum v3.6 #28). Small and above the number,
   so the card still reads as figures rather than as a form. */
.wx-name {
  display: block;
  font-size: 10px;
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: .04em;
  color: var(--ink-muted);
}
/* An absent irradiance keeps its slot and is visibly absent, rather than
   vanishing and leaving a card that looks complete. */
.wx-absent { color: var(--ink-muted); }
.wx-unit { font-size: 11px; font-weight: 400; color: var(--ink-muted); margin-left: 3px; }
.wx-sub { margin-top: 3px; font-size: 11px; color: var(--ink-muted); }
.wx-none { display: flex; flex-direction: column; gap: 3px; }

/* A Station card on the dashboard is a link now — it was a dead block before,
   which meant the one screen most people open first was the one screen you
   could not click through from. */
.card-link { display: block; color: inherit; text-decoration: none; }
.card-link:hover { border-color: var(--ink-muted); }

/* A Station card's three figures. `card-metrics` is a fixed three-column grid
   that suited "3.5 kW / 8.2 kW / 61%"; a Plant's grid figure is "226.78 kW" and
   ran straight into the percentage beside it. Sized to content with a real gap,
   and allowed to wrap to two rows on a narrow card rather than overlapping. */
.station-metrics {
  grid-template-columns: repeat(auto-fit, minmax(min(78px, 100%), 1fr));
  gap: 6px 14px;
}
.station-metrics .v { font-size: 15px; white-space: nowrap; }

/* How long a Station has been in trouble. Coloured like the state it describes,
   because it is only shown for a Station that needs attention and it should read
   as part of the alarm rather than as another grey detail.

   Two ways in, because the state is carried differently in the two places this
   appears: a fleet tile already has its state as a class on the tile, and a
   dashboard card does not, so there it is a `data-state` on the line itself. */
.state-since { font-size: 12px; font-weight: 600; margin-bottom: 1px; }
.tile-stale .state-since, .tile-fault .state-since,
.state-since[data-state="stale"], .state-since[data-state="fault"] { color: var(--critical); }
.tile-degraded .state-since, .state-since[data-state="degraded"] { color: var(--warning); }
.tile-unknown .state-since, .tile-silenced .state-since,
.state-since[data-state="unknown"] { color: var(--ink-muted); }
/* Dropped before the specs at dense settings, but AFTER them at compact: how long
   something has been broken outranks its panel capacity when space is short.
   Scoped to the tile, because density is a property of the fleet grid and the
   dashboard's cards do not have it. */
:root[data-fleet-density="dense"] .fleet-tile .state-since { display: none; }

/* --- the live read (T-23) -------------------------------------------------
   Deliberately unlike a value card. Everything else on the site page comes
   from the stored series and is up to fifteen minutes old; this is straight
   from the vendor and is never stored. If the two looked alike, somebody would
   reconcile the tile against the chart and report a platform that is working
   correctly as broken. Dashed, tinted, and labelled with its own age. */
.live-strip {
  border: 1px dashed var(--border);
  border-radius: 8px;
  padding: 10px 12px;
  margin-bottom: 14px;
  background: color-mix(in srgb, var(--good) 7%, transparent);
}
.live-strip .live-head {
  display: flex;
  align-items: baseline;
  gap: 8px;
  flex-wrap: wrap;
  margin-bottom: 8px;
}
.live-strip .live-title { font-weight: 600; font-size: 14px; }
.live-strip .live-figures {
  display: flex;
  gap: 18px;
  flex-wrap: wrap;
}
.live-strip .live-figure { display: flex; flex-direction: column; }
.live-strip .live-figure b { font-size: 18px; font-variant-numeric: tabular-nums; }
.live-strip .live-figure span { font-size: 12px; color: var(--ink-2); }
.live-strip .live-stale { color: var(--warning); }
/* T-127. The tint above is `--good`, which is a health colour, and a tile whose
   own words say the figures may not be current must not be sitting in it. The
   incident was a Station that stopped reporting at 17:15 while this tile said
   "just now": the text was fixed first, and a warning printed on a green
   background is the same mistake one layer down. */
.live-strip.is-frozen {
  background: color-mix(in srgb, var(--warning) 7%, transparent);
  border-color: var(--warning);
}

/* --- Station logo (§83, B-12) ------------------------------------------- */

.logo-row { display: flex; gap: 16px; align-items: flex-start; }

/* Same collapse-when-empty rule as the card slot, for the same reason: a
   Station without a logo should show no gap where one would go. */
.logo-preview:empty { display: none; }
.logo-preview {
  width: 132px;
  height: 92px;
  flex: none;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--surface-2);
  overflow: hidden;
}

.logo-preview img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: contain;
  padding: 8px;
  box-sizing: border-box;
}

/* --- §8: the fleet instrument panel around the map ---------------------- */

.fleet-panel {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
  align-items: stretch;
  margin-bottom: 14px;
}

.fleet-tiles {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(150px, 100%), 1fr));
  gap: 12px;
  flex: 1 1 520px;
}

.fleet-tile-stat {
  border: 1px solid var(--border);
  border-radius: 10px;
  background: var(--surface);
  padding: 10px 12px;
}

.tile-label { font-size: 11px; letter-spacing: .04em; text-transform: uppercase; color: var(--ink-muted); }
.tile-value { font-size: 22px; font-weight: 600; margin-top: 2px; }
.tile-sub   { font-size: 11px; color: var(--ink-muted); margin-top: 2px; }
.unit       { font-size: 13px; font-weight: 400; color: var(--ink-muted); }

/* The status roll-up. Its own column beside the tiles at width, stacked under
   them when there is not room. */
.fleet-roll {
  display: flex;
  flex-direction: column;
  gap: 4px;
  justify-content: center;
  flex: 0 1 220px;
  border: 1px solid var(--border);
  border-radius: 10px;
  background: var(--surface);
  padding: 10px 12px;
}

.roll-item { font-size: 12px; color: var(--ink-muted); }
.roll-item strong { display: inline-block; min-width: 2ch; color: var(--ink); font-size: 14px; }
.roll-ok      strong { color: var(--good); }
.roll-degraded strong { color: var(--warning); }
.roll-fault   strong,
.roll-stale   strong { color: var(--critical); }

/* `.fleet-energy` is GONE with the block it laid out. The figures and the Energy
   monitoring chart moved into the map's RIGHT PANEL on 2026-08-21 (T-71), where
   the reference mockup puts them, so a two-column grid under the map has nothing
   left to arrange. Removed rather than left: unused CSS reads as load-bearing to
   the next person and gets copied into the next screen. */

.fleet-figures {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(130px, 100%), 1fr));
  gap: 10px 14px;
}

.fleet-figure .tile-value { font-size: 19px; }

.fleet-notes {
  margin: 10px 0 0;
  padding-left: 18px;
  font-size: 12px;
  color: var(--ink-muted);
}

/* The bar chart. Inline SVG, so its axis and gridlines need styling here -
   the other charts on this site are canvas and have none to share. */
.fleet-bars { width: 100%; height: auto; display: block; }
.fleet-bars .grid-line { stroke: var(--border); stroke-width: 1; }
.fleet-bars .axis { fill: var(--ink-muted); font-size: 11px; }

.chart-legend { display: flex; flex-wrap: wrap; gap: 4px 14px; margin-top: 8px; font-size: 12px; }
.legend-swatch {
  display: inline-block;
  width: 10px;
  height: 10px;
  border-radius: 2px;
  margin-right: 5px;
  vertical-align: middle;
}

/* ---------------------------------------------------------------------------
   The platform's own machinery has stopped.

   Not a site alert - those belong to a customer and live on the alerts screen.
   This says the figures on the screen behind it may be old, which is a fact
   about this installation and is why it sits above everything rather than
   inside a card.

   Mounted by nav.js on every screen, for operators holding `settings.manage`.
   The August rollup outage ran for two days with every dashboard rendering its
   last figures and nothing anywhere saying so.
--------------------------------------------------------------------------- */
.pipeline-banner {
  /* In normal flow rather than fixed or sticky. The topbar is already sticky at
     top: 0, and two elements claiming the same edge overlap the moment somebody
     scrolls - so this pushes the page down instead of floating over it. The one
     thing that does NOT flow is the sidebar, which is fixed, and it is shifted
     by the height measured below. */
  z-index: 60;
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: 10px 16px;
  font-size: 13px;
  line-height: 1.45;
  color: var(--ink);
  border-bottom: 1px solid color-mix(in srgb, var(--warning) 55%, transparent);
  border-left: 4px solid var(--warning);
  background: color-mix(in srgb, var(--warning) 26%, var(--plane));
}

.pipeline-banner strong { font-size: 14px; }

/* A tier that has actually stopped is the customer-visible one, so it is the
   louder colour. `idle` and `failing` are warnings about what is coming. */
.pipeline-banner--stale {
  border-bottom-color: color-mix(in srgb, var(--critical) 55%, transparent);
  border-left-color: var(--critical);
  background: color-mix(in srgb, var(--critical) 30%, var(--plane));
}

/* The lag sentences are long. Wrapping is correct here - truncating the reason
   would leave a banner that says something is wrong and not what. */
.pipeline-banner span { overflow-wrap: anywhere; }

/* The sidebar is `position: fixed`, so it does not move when the banner takes
   space at the top of the document - it would sit underneath it and lose its
   first entry. The height is measured after render and published as a custom
   property, because the banner is one line or four depending on how many tiers
   are unwell - and since T-133 it is the SUM of however many banners are up. */
:root[data-pipeline-banner] .tabs {
  top: calc(var(--topbar-height) + var(--pipeline-banner-height, 0px));
}

/* ---------------------------------------------------------------------------
   DATA QUALITY (T-151)

   Only ONE number on that page is shaded: the share of expected rows that
   arrived, which is comparable against 100% on every Station because the
   expectation comes from each device's own interval. A field's percentage is
   deliberately left plain - some connectors leave a field empty where there was
   nothing to report, so shading PV would paint every grid-tie Station red every
   night, and a screen that is red when nothing is wrong gets closed.
--------------------------------------------------------------------------- */
.dq-pct {
  display: inline-block;
  min-width: 4.5em;
  padding: 1px 6px;
  border-radius: 5px;
  font-variant-numeric: tabular-nums;
  text-align: right;
}

.dq-good { background: color-mix(in srgb, var(--good) 26%, transparent); }
.dq-fair { background: color-mix(in srgb, var(--warning) 30%, transparent); }
.dq-poor { background: color-mix(in srgb, var(--critical) 32%, transparent); }

/* ---------------------------------------------------------------------------
   THE TIME-OF-USE TIMETABLE (T-147)

   The grid MetSolar's own performance report ends with: 48 half-hour cells
   across, three day types down, every cell coloured by period. It sits under the
   window pills rather than replacing them - a cell says "this half hour is peak",
   a pill says "the window is 07:00-10:00", and somebody transcribing a card needs
   the second while somebody checking one needs the first.

   `unset` is a colour of its own and not a shade of off-peak. Two junk cards in
   the library have no windows at all, and on this grid they must read as blank
   rather than as a plausible wall of green.
--------------------------------------------------------------------------- */
.tou-grid-wrap { margin-top: 10px; overflow-x: auto; }

.tou-grid {
  border-collapse: collapse;
  /* Half-hour cells at 9px are 432px of grid plus the row label - narrower than
     the drawer at every width we support, so the wrapper only ever scrolls on a
     phone. */
  table-layout: fixed;
  font-size: 10px;
  color: var(--ink-2);
}

.tou-grid th[scope="col"] {
  font-weight: 400;
  padding: 0 0 2px;
  text-align: left;
  white-space: nowrap;
}

.tou-grid th[scope="row"] {
  padding: 0 8px 0 0;
  font-weight: 500;
  font-size: 11px;
  text-align: right;
  white-space: nowrap;
  color: var(--ink);
}

.tou-cell {
  width: 9px;
  height: 14px;
  padding: 0;
  border: 1px solid var(--plane);
  /* No window recorded. Deliberately the page background rather than a colour,
     so a card with no clock reads as missing and not as off-peak. */
  background: color-mix(in srgb, var(--ink-2) 12%, transparent);
}

/* Peak is the expensive hour and takes the loud colour; off-peak is the cheap one
   and takes the calm one. The same three colours the report prints. */
.tou-cell.tou-peak     { background: color-mix(in srgb, var(--critical) 65%, transparent); }
.tou-cell.tou-standard { background: color-mix(in srgb, var(--warning) 62%, transparent); }
.tou-cell.tou-off_peak { background: color-mix(in srgb, var(--good) 55%, transparent); }

.tou-legend {
  display: flex;
  flex-wrap: wrap;
  gap: 4px 14px;
  margin-top: 6px;
  font-size: 11px;
  color: var(--ink-2);
}

.tou-key { display: inline-flex; align-items: center; gap: 5px; }

/* The legend's swatch is the same element as a cell, so the two can never drift
   apart into different colours for the same period. */
.tou-key i { display: inline-block; }

/* ---------------------------------------------------------------------------
   THE SESSION HAS ENDED (T-133)

   Same shape as the banner above and a different subject: that one says the
   platform's figures are old, this one says this browser is no longer signed in.
   Both can be true at once, which is why the offset above sums them.

   Deliberately NOT a redirect to the sign-in screen. This platform has forms
   that take minutes to fill, and taking the document away to tell somebody about
   a cookie loses the work. The figures stay, the asking stops, and this says so.
--------------------------------------------------------------------------- */
.session-banner {
  /* In flow, above the sticky topbar's stacking context, like the pipeline
     banner - two elements claiming `top: 0` overlap the moment anybody
     scrolls. */
  z-index: 61;
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 4px 10px;
  padding: 10px 16px;
  font-size: 13px;
  line-height: 1.45;
  color: var(--ink);
  border-bottom: 1px solid color-mix(in srgb, var(--critical) 55%, transparent);
  border-left: 4px solid var(--critical);
  background: color-mix(in srgb, var(--critical) 30%, var(--plane));
}

.session-banner strong { font-size: 14px; }

/* The one thing on it that acts, so it does not read as another sentence. */
.session-banner a {
  margin-left: auto;
  padding: 3px 10px;
  font-weight: 600;
  white-space: nowrap;
  color: var(--ink);
  border: 1px solid color-mix(in srgb, var(--critical) 70%, transparent);
  border-radius: 6px;
  background: var(--plane);
  text-decoration: none;
}

.session-banner a:hover,
.session-banner a:focus-visible { border-color: var(--critical); }


/* ---------------------------------------------------------------------------
   A TOGGLE SWITCH, not a tickbox
   ---------------------------------------------------------------------------
   Addendum v3.6 #26 asks for "Show all stations" to render "as a genuine toggle
   switch, not a plain checkbox - no behaviour change, display only", and #26
   also asks Station Settings for "a clear, dedicated action" for the O&M flag.
   Both are the same control, so both use this one.

   The input is a REAL checkbox, kept in the accessibility tree and operable
   from the keyboard, and moved out of sight rather than hidden with
   `display: none` - which would take it out of the tab order and out of forms.
   The track and knob are drawn on the `.switch-track` beside it. Nothing here
   changes what the control does; a switch and a tickbox submit the same value.
--------------------------------------------------------------------------- */
.switch { display: inline-flex; align-items: center; gap: 9px; cursor: pointer; }

/* A switch used as a FORM FIELD: the control on one line, its explanation
   beneath. Its own class rather than reusing `.check`, which is a flex ROW and
   would put a two-sentence hint beside the toggle instead of under it - and is
   used by nine other screens that are not changing (T-101). */
.switch-field { display: flex; flex-direction: column; align-items: flex-start;
                gap: 6px; margin: 18px 0 20px; }
.switch-field > small { color: var(--ink-muted); font-weight: 400; font-size: 12px;
                        line-height: 1.45; }

.switch input {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px; padding: 0; border: 0;
  clip: rect(0 0 0 0); clip-path: inset(50%);
  overflow: hidden; white-space: nowrap;
}
.switch-track {
  position: relative;
  flex: none;
  width: 34px; height: 20px;
  border-radius: 999px;
  background: var(--border);
  transition: background 120ms ease;
}
.switch-track::after {
  content: '';
  position: absolute;
  top: 2px; left: 2px;
  width: 16px; height: 16px;
  border-radius: 50%;
  background: var(--surface);
  box-shadow: 0 1px 2px rgb(0 0 0 / 0.35);
  transition: transform 120ms ease;
}
.switch input:checked + .switch-track { background: var(--good); }
.switch input:checked + .switch-track::after { transform: translateX(14px); }
/* Focus has to be visible on the track, because the input it belongs to is not
   on screen. Without this the control is unusable from a keyboard. */
.switch input:focus-visible + .switch-track { outline: 2px solid var(--series-1); outline-offset: 2px; }
.switch input:disabled + .switch-track { opacity: 0.5; cursor: not-allowed; }

/* The dedicated O&M control on a Station Settings screen (#26). A row of its
   own with the consequence spelled out beside it, rather than a tickbox in a
   list of twenty fields - which is what it was, and why the spec calls for
   something clearer. */
.om-action { display: flex; align-items: flex-start; gap: 12px; flex-wrap: wrap; }
.om-action .switch { padding-top: 2px; }
.om-action small { display: block; max-width: 46ch; }


/* ---------------------------------------------------------------------------
   The graphs' range picker (Addendum v3.6 #20)
--------------------------------------------------------------------------- */
.range > summary {
  cursor: pointer;
  font-size: 13px;
  color: var(--ink-muted);
  padding: 4px 8px;
  border: 1px solid var(--border);
  border-radius: 8px;
  list-style: none;
}
.range > summary::-webkit-details-marker { display: none; }
.range > summary::after { content: ' ▾'; }
.range[open] > summary::after { content: ' ▴'; }
.range-body {
  position: absolute;
  right: 0;
  z-index: 5;
  margin-top: 6px;
  padding: 12px;
  width: min(420px, 92vw);
  border: 1px solid var(--border);
  border-radius: 10px;
  background: var(--surface);
  box-shadow: 0 8px 24px rgb(0 0 0 / 0.35);
}
.range { position: relative; }
.range-presets { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }
.range-presets button {
  font-size: 12px;
  padding: 4px 9px;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--surface-2);
  color: inherit;
  cursor: pointer;
}
.range-presets button.is-current { border-color: var(--series-1); color: var(--series-1); }

/* T-123's toggle, lit in the colour of the line it draws - so "on" is readable
   from the control itself rather than only from the chart. It matters at night:
   the forecast is legitimately flat at zero for hours, and a pressed button whose
   only feedback is a line would then look broken. Same treatment as the range
   presets above, which is where the pattern comes from. */
#forecast-toggle[aria-pressed="true"] {
  border-color: var(--series-forecast);
  color: var(--series-forecast);
}

/* A preset that does not apply to the tab you are on. It stays visible rather
   than disappearing - the set of ranges is part of understanding what the picker
   does - but it must not look pressable. `cursor: not-allowed` and the opacity do
   the work; the `title` on the element says why. */
.range-presets button:disabled {
  opacity: 0.4;
  cursor: not-allowed;
  border-color: var(--border);
  color: var(--ink-muted);
}
.range-custom { display: flex; flex-wrap: wrap; align-items: end; gap: 8px; }
.range-custom label { display: flex; flex-direction: column; font-size: 11px; color: var(--ink-muted); gap: 3px; }
.range-custom input { padding: 4px 6px; }

/* ---------------------------------------------------------------------------
   The report field picker (T-47), in Station Settings.

   Fifty-odd checkboxes in twelve groups. Laid out in columns so the whole set is
   readable without scrolling past it, and a field that cannot apply to this site
   is dimmed and given its reason rather than hidden - somebody looking for
   "Cost" on a site with no tariff should find it with an explanation.
--------------------------------------------------------------------------- */
/* These three exist because the markup may carry NO `style=` attribute: the app
   pages run under `style-src 'self'` with no `unsafe-inline`, so an inline style
   is blocked outright and the browser says so in the console. The report
   DOCUMENT is the exception - it carries its own CSP - and confusing the two is
   easy, which is how three of them shipped into site.html for ten minutes. */
.report-fields-head { margin-top: 14px; }
.report-fields-head h4 { margin: 0; font-size: 13px; }
.report-save-row { margin-top: 12px; }

/* GRID, NOT `columns`. The first version used CSS multi-column, and a
   multi-column box FRAGMENTS its content: with the label as a flex row the
   browser broke the labels one word per line and spilled a group across the
   column boundary, so a tick appeared to belong to the field above it - the
   worst possible failure for a picker whose whole job is saying which field is
   selected. Grid places each group as a whole and never splits one. */
#report-fields {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
  gap: 4px 22px;
  margin-top: 4px;
  align-items: start;
}
.report-group { border: 0; margin: 0 0 10px; padding: 0; min-width: 0; }
.report-group legend {
  font-size: 10px; text-transform: uppercase; letter-spacing: 1.2px;
  color: var(--ink-muted); padding: 0; margin-bottom: 3px;
}
.report-field {
  display: flex; align-items: flex-start; gap: 7px;
  font-size: 12.5px; margin: 4px 0; cursor: pointer; line-height: 1.35;
}
/* `width: auto` IS THE WHOLE FIX, and everything else here was chasing its
   symptoms. app.css:119 sets `input { width: 100% }` - right for the text fields
   this form is mostly made of - so every checkbox in this picker was 285px wide,
   the full grid column, and shoved its own label onto the next line. Measured:
   `inputW: 285` beside a 39px text node.
   
   That produced three rounds of blaming the layout: multi-column fragmentation,
   then flex, then `overflow-wrap`. None of them was the cause and one of them
   made it worse. `.check input { width: auto }` at line 221 had solved the same
   problem for the switch controls long before I arrived. */
.report-field input[type="checkbox"] {
  width: auto;
  flex: 0 0 auto;
  margin: 2px 0 0;
  accent-color: var(--series-1);
}
/* `min-width: 0` lets the text half shrink and wrap normally; without it a flex
   item refuses to go below its longest word plus its `<em>` reason and the grid
   overflowed its card by 130px.
   
   NO `overflow-wrap: anywhere`. That was tried and it broke every label to ONE
   CHARACTER PER LINE - it permits a break at any point, and inside a narrow flex
   item "any point" means all of them. The residual ~38px of overflow is one long
   label and is not worth a rule that can do that. */
.report-field { min-width: 0; }
.report-field em { font-style: normal; font-size: 10.5px; color: var(--series-4); }
/* Not built yet: shown so nobody has to wonder whether it was forgotten, and
   disabled so it cannot be chosen. */
.report-field.is-todo { color: var(--ink-muted); cursor: not-allowed; }
.report-field.is-todo em { color: var(--ink-muted); }

/* ---------------------------------------------------------------------------
   THE TARIFF DATA-WARNING COLUMN (T-150)

   Two severities and they must not look alike. A `fault` is a statement about
   the data - no tariff assigned, no version covering the period, a time-of-use
   card with no hours - and something priced under it WILL be wrong. A `suspect`
   is a guess: a card whose name reads like test data, a provisional version, a
   PPA rate on a Station with no capacity recorded. Rendering a guess in the same
   red as a fact is how a warning column stops being read, so the guess gets the
   warning colour and a left rule rather than the critical one.

   Left-ruled rather than filled, because a filled cell of prose is harder to
   read than the prose is worth - and this column is prose on purpose. "No grid
   tariff defined after 2025-06-30" names the boundary and the date, which is the
   difference between a warning somebody acts on and one they investigate.
--------------------------------------------------------------------------- */
.cov-warn {
  border-left: 3px solid var(--ink-muted);
  padding: 2px 0 2px 8px;
  margin: 2px 0;
  font-size: 0.86rem;
  line-height: 1.35;
  max-width: 42ch;
}

.cov-fault   { border-left-color: var(--critical); }
.cov-suspect { border-left-color: var(--warning); color: var(--ink-2); }


/* ---------------------------------------------------------------------------
   THE PORTFOLIO YEAR SHEET (T-149)

   Four states in one column of numbers, and they must be told apart at a glance
   because the sheet goes to a customer. `future` is EMPTY - a month that has not
   happened is not a gap and must not draw the eye. `none` is a dash in muted
   ink: the month happened and nothing was recorded. A `*` marks a figure that
   covers only part of its month, so it is not comparable with the column beside
   it. And a zero is none of those - it renders as an ordinary 0, because the
   array reported and generated nothing, which is a measurement (LESSONS 19).

   The stacked segments cycle through eight series colours. Eight rather than one
   per Station: the fleet is eighteen and a scale of eighteen distinguishable
   hues does not exist. Identity comes from the tooltip; the chart's job is the
   portfolio total and the rough shape of who contributes to it.
--------------------------------------------------------------------------- */
.pf-none    { color: var(--ink-muted); }
.pf-future  { background: color-mix(in srgb, var(--ink-muted) 5%, transparent); }
.pf-partial { color: var(--warning); font-weight: 600; margin-left: 1px; }

/* The key in the note above the table, so the words and the cells match. */
.pf-key {
  padding: 0 5px;
  border-radius: 4px;
  font-size: 0.85em;
}
.pf-key.pf-none   { background: var(--surface-2); }
.pf-key.pf-future { background: color-mix(in srgb, var(--ink-muted) 10%, transparent); }

.pf-chart { width: 100%; height: auto; }
.pf-grid  { stroke: var(--border); stroke-width: 1; }
.pf-axis  { fill: var(--ink-muted); font-size: 11px; }

.pf-seg-0 { fill: var(--series-1); }
.pf-seg-1 { fill: var(--series-2); }
.pf-seg-2 { fill: var(--series-3); }
.pf-seg-3 { fill: var(--series-4); }
.pf-seg-4 { fill: var(--series-5); }
.pf-seg-5 { fill: var(--series-6); }
.pf-seg-6 { fill: var(--series-7); }
.pf-seg-7 { fill: var(--series-8); }
.pf-seg   { stroke: var(--surface); stroke-width: 0.5; }

/* ---------------------------------------------------------------------------
   THE BIND PICKER ON THE CONNECTIONS PAGE — T-176.

   Rainer, 2026-08-26: "I can see this will become a problem." One GoodWe
   account discovered 71 systems against 18 Stations, and the flat `<select>`
   this replaces listed every Station on the platform, ungrouped — so two
   Customers with an "Office" were indistinguishable at the moment of binding.

   Customers on the left, the hovered Customer's Stations on the right. Shaped
   like `.range-body`, which is the platform's other floating panel, and closed
   by the same module.
--------------------------------------------------------------------------- */
.bindmenu { position: relative; display: inline-block; }
.bindmenu > summary {
  /* A `<summary>` is a list-item by default and draws a triangle. This one is a
     button and reads as one; the disclosure marker would say "expands in place",
     which is the opposite of what the panel does. */
  list-style: none;
  cursor: pointer;
  display: inline-block;
  padding: 6px 12px;
  border: 1px solid var(--border);
  border-radius: 8px;
  font-size: 14px;
}
.bindmenu > summary::-webkit-details-marker { display: none; }
.bindmenu > summary::marker { content: ''; }
.bindmenu[open] > summary { background: var(--surface-2); }

.bindmenu-body {
  position: absolute;
  right: 0;
  z-index: 30;
  margin-top: 6px;
  width: min(560px, 92vw);
  border: 1px solid var(--border);
  border-radius: 10px;
  background: var(--surface);
  box-shadow: 0 8px 24px rgb(0 0 0 / 0.35);
  text-align: left;
}

/* The backfill panel — T-198. Reuses `.bindmenu-body`'s floating box, because it
   floats for the same reason and closing on click-away is the same behaviour; only
   the contents differ. */
.backfill-form {
  padding: 12px;
  display: grid;
  gap: 10px;
  /* `minmax(0, 1fr)` RATHER THAN THE DEFAULT `auto`, and this is the whole of why the
     panel laid out wrongly the first time. A grid item's `min-width` is `auto`, which
     resolves to its MIN-CONTENT width and refuses to shrink below it — so the
     explanatory paragraphs sized the column to their own max-content and the form's
     children came out 1,479px wide inside a 560px box, overflowing sideways.

     No assertion saw it: `getBoundingClientRect()` on the panel returned 560 and
     "fits on screen" was true of the BOX while its contents ran off the page. The
     screenshot is what showed it — LESSONS 75, measure the contents AND take the
     picture. `min-width: 0` below is the same fix one level down, for the flex rows. */
  grid-template-columns: minmax(0, 1fr);

  /* `th, td { white-space: nowrap }` — line 523 — is INHERITED into this panel,
     because it floats out of a table cell rather than out of the document. That is
     right for a table of one-line figures and wrong for a paragraph: every
     explanation here ran as one long line, 1,080px of text inside a 534px column,
     and the box measured 560 the whole time. `.bindmenu-body` sets `text-align:
     left` for the same reason and stops there, because its own contents are all
     single-line buttons.

     Any floating panel with prose in it needs both resets. */
  white-space: normal;
  text-align: left;
}
.backfill-form > * { min-width: 0; }
.backfill-form p { margin: 0; line-height: 1.45; }
/* A GRID RATHER THAN A FLEX ROW, so the two boxes line up under each other. With
   `flex` the input starts wherever its label's text ends, and "From" is wider than
   "To" — two fields a few pixels out of true, which reads as sloppiness rather than
   as a bug and is the kind of thing only a picture shows. */
.backfill-form label {
  display: grid;
  grid-template-columns: 3.25rem auto;
  gap: 8px;
  align-items: center;
  font-size: 14px;
  margin-bottom: 0;
}
/* A DATE BOX DOES NOT GET WIDER THAN A DATE — and the reason is `input { width: 100% }`
   at line 119, which this file already carries two notes about. `flex: 0 0 auto` does
   NOT save you: flex-basis `auto` defers to the used `width`, which is 100%, so the
   two date fields came out 534px each and ran past the edge of a 560px panel.
   An explicit width is the only thing that settles it. Third time for this rule. */
.backfill-form input[type="date"] {
  flex: 0 0 auto;
  width: auto;
  max-width: 190px;
  font: inherit;
  padding: 4px 6px;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--surface-2);
  color: var(--ink);
}
.backfill-stamp {
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 8px 10px;
  display: grid;
  gap: 6px;
  /* The same two fixes one level down: this is a grid inside the grid, and its own
     items would refuse to shrink below their min-content just as the outer ones did. */
  grid-template-columns: minmax(0, 1fr);
  white-space: normal;
}
.backfill-stamp > * { min-width: 0; }
/* Not `flex` like the date rows: a radio and its words are one line of text, and
   flex would stretch the label across the whole column. */
.backfill-stamp label { display: block; grid-template-columns: none; font-weight: 400; margin-bottom: 0; }
/* `input { width: 100% }` FOR THE THIRD TIME IN ONE PANEL — a radio given the full
   column width renders its dot centred above the words instead of beside them, which
   reads as two unlabelled buttons. `.check input` and `.perm input` in this file are
   the same two lines for the same reason; this is the third, and the pattern is that
   ANY input which is not a text box needs `width: auto` written out. */
.backfill-stamp input[type="radio"] { width: auto; margin: 0 6px 0 0; accent-color: var(--series-1); }
.backfill-stamp legend { font-size: 13px; padding: 0 4px; }
.backfill-stamp label { gap: 6px; }
.backfill-actions { display: flex; gap: 8px; }
/* The progress line for a queued job — T-200. Hidden until there is a job, so the
   panel does not reserve a blank row for the ordinary case. */
.backfill-progress {
  display: flex;
  gap: 8px;
  align-items: center;
  flex-wrap: wrap;
  white-space: normal;
}
.backfill-progress progress { flex: 1 1 140px; min-width: 0; height: 8px; }
.backfill-progress span { flex: 1 1 100%; }
/* Empty until something is said, so the panel does not reserve a blank line. */
.backfill-form [data-backfill-result]:empty { display: none; }

.bindmenu-head {
  display: flex;
  gap: 8px;
  align-items: center;
  padding: 10px;
  border-bottom: 1px solid var(--border);
}
.bindmenu-filter { flex: 1 1 auto; min-width: 0; font-size: 14px; }

.bindmenu-cols {
  display: grid;
  /* Two fixed tracks rather than auto: the right-hand pane's width must not
     change as you move down the list of Customers, or the Stations jump about
     under the pointer on the way to them. */
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}

/* Bounded and scrollable. Seven Customers fit; seventy would otherwise run off
   the bottom of the viewport, and a panel taller than the screen cannot be
   closed by clicking away from it because there is no "away".

   THE CAP IS ON THE PANES, NOT ON `.bindmenu-cols`, and that is the whole fix
   for the overflow Rainer reported on 2026-08-26. It was on the grid container,
   with `overflow-y: auto` on each pane, and it looked right: the container
   measured exactly 340px. But a grid ROW TRACK is sized by its content, and a
   `max-height` on the container does not shrink it — so a Customer with fifteen
   Stations made a 615px pane inside a 340px box, which hung 274px out of the
   bottom of the panel and drew over the rows behind it. The pane never
   scrolled, because nothing had told the PANE a height; only its parent.
   Measured on CTP: seven of fifteen Stations outside the panel.

   So each pane carries the bound itself. The grid row is then sized by panes
   that are already clamped, both panes stretch to it, and each scrolls its own
   list. */
.bindmenu-customers,
.bindmenu-stations {
  max-height: min(46vh, 340px);
}

.bindmenu-customers {
  margin: 0;
  padding: 6px;
  list-style: none;
  overflow-y: auto;
  border-right: 1px solid var(--border);
}
.bindmenu-customer {
  display: flex;
  gap: 8px;
  align-items: center;
  width: 100%;
  padding: 7px 9px;
  border: 0;
  border-radius: 7px;
  background: none;
  color: var(--ink);
  font: inherit;
  font-size: 14px;
  text-align: left;
  cursor: pointer;
}
.bindmenu-customer:hover,
.bindmenu-customer:focus-visible,
.bindmenu-customer.is-active { background: var(--surface-2); }
.bindmenu-name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bindmenu-count { flex: 0 0 auto; color: var(--ink-muted); font-size: 12px; font-variant-numeric: tabular-nums; }

.bindmenu-stations { padding: 10px; overflow-y: auto; }
.bindmenu-stations ul { margin: 0 0 8px; padding: 0; list-style: none; }
.bindmenu-for {
  margin: 0 0 6px;
  color: var(--ink-muted);
  font-size: 12px;
  text-transform: uppercase;
  letter-spacing: 0.04em;
}
.bindmenu-station {
  display: block;
  width: 100%;
  padding: 7px 9px;
  border: 0;
  border-radius: 7px;
  background: none;
  color: var(--ink);
  font: inherit;
  font-size: 14px;
  text-align: left;
  cursor: pointer;
}
.bindmenu-station:hover, .bindmenu-station:focus-visible { background: var(--surface-2); }

.bindmenu-none { margin: 0; padding: 6px 9px; color: var(--ink-muted); font-size: 13px; }
.bindmenu-add { font-size: 13px; white-space: nowrap; }

.bindmenu-new { display: flex; gap: 6px; align-items: center; padding: 4px 0; }
.bindmenu-new-name { flex: 1 1 auto; min-width: 0; font-size: 14px; }

/* T-181. A container whose children must be grid items of the grid ABOVE it, not of
   itself — the account form's credential fields are generated per vendor, so they
   arrive inside one element and would otherwise sit in a single cell.

   A class rather than a style attribute, and the harness is why: `style="display:
   contents"` on the div violated `style-src 'self'` and `tests/ui/connections.mjs`
   failed on the console message. A CSP that refuses inline style refuses it wherever
   it appears, which is the point of having one. */
.grid-passthrough { display: contents; }
