/* The whole appearance, in one file, served from /assets/app.css.
 *
 * Every value that repeats is a token in :root and nothing else, which is the
 * property the two scans in http/assets.rs hold: no colour literal outside the
 * block below, and no `content:` property carrying a word.
 *
 * `:root` is TWO layers, and only the second one obeys that rule. The palette
 * layer carries each value under its source's own name -- `--brandMagenta`
 * from the customer app, `--sp-24` and `--h2` from the shop -- so every value
 * in this interface is greppable against where it came from, which is what
 * makes it a record rather than a claim. The role
 * layer above it says what a value is *for* -- `--cell-pad-block`, not
 * `--space-3` -- and resolves into the palette by `var()`. A name that
 * describes the role is one a later change can move without hunting: the
 * register rules that follow can override a role, and could not override a
 * number. */
/* --- the face ---------------------------------------------------------
 *
 * Inter, the face the customer app ships in place of its brand's own, which
 * is licensed and not ours to redistribute (#96). SIL Open Font License 1.1;
 * the licence travels in `assets/licences/Inter-OFL.txt`. Served from **this**
 * origin: a `@font-face` pointing at a third-party CDN would be a licence
 * question and a third-party request on a sign-in page, which is two reasons
 * and either would do.
 *
 * ONE face and no display face. The Calm heading is set in Inter too, and
 * Inter carries its own display cut: the file keeps both of its axes, `wght`
 * 100-900 and `opsz` 14-32, so with `font-optical-sizing: auto` -- both
 * engines' default, and not written here for that reason -- a 32-56 px Calm
 * heading draws the display cut and 16 px body text the text cut, which is
 * what the app gets from the same file. The optical axis needs no
 * descriptor; the weight axis does, or an engine synthesises bold.
 *
 * Cut from `app/assets/fonts/InterVariable.ttf` (Inter 4.001) with fontTools
 * 4.62.1, so a re-cut is repeatable:
 *
 *   pyftsubset app/assets/fonts/InterVariable.ttf \
 *     --unicodes="U+0020-007E,U+00A0-00FF,U+2013-2014,U+2026,U+2191,U+2193" \
 *     --layout-features+=tnum --flavor=woff2 \
 *     --output-file=backend/assets/static/inter-latin-20260917a.woff2
 *
 * The ranges are printable ASCII, Latin-1, and every other character the
 * interface's copy and templates carry, derived rather than remembered:
 * `grep -rhoP '[^\x00-\x7F]' backend/src/http/copy.rs backend/assets/templates`
 * prints `– — … ↑ ↓` beyond Latin-1, and `✕`, which Inter does not draw and
 * the system fallback does. `tnum` is added to pyftsubset's default features
 * because Inter's figures are proportional by default and `table` asks for
 * `tabular-nums`; without it the request is silently ignored.
 *
 * `font-display: swap` because the fallback stack in `--ff` is the do-nothing
 * outcome and is readable: text renders in it immediately rather than waiting
 * on a network the equipment room may not have.
 *
 * The name carries the date it was cut and a serial, and the route that
 * serves it answers `immutable`. A re-cut subset is a new date, which is a new
 * name -- which is what `immutable` means. */
@font-face {
  font-family: "Inter";
  font-style: normal;
  font-weight: 100 900;
  font-display: swap;
  src: url("/assets/inter-latin-20260917a.woff2") format("woff2");
}

:root {
  /* === the palette ========================================================
   *
   * Three sources, each under its own names. **Colour** is the customer
   * app's, read from `app/lib/design/tokens.dart` (#85), so the web
   * interface and the phone carry one brand: each colour is named as
   * `SsColors` names it and says so beside it, verbatim, and
   * `every_palette_token_names_its_source_token` holds that. **Shape** is the
   * app's too since #96, each radius named as `SsRadii` names it and held by
   * the same test. **Space, type, the container width and the shadow** are
   * still the shop's, from its `main.css`: the owner kept the admin pages'
   * own density (#96), so the app's phone-screen spacing is not carried.
   *
   * **The chart's colours are Grafana's** (#124): ten shades of its `Classic
   * palette` as the light theme resolves them, pinned to `grafana/grafana` at
   * commit b3648173d5e4a346bd69019af3c2e56bf9daa670,
   * `createVisualizationColors.ts:218-268`. They are the one source whose
   * values are not all verbatim: a shade that fails the 3:1 a graphic needs on
   * the white panel is darkened, its lightness lowered in OKLCH with the hue held, and
   * its chroma reduced only as far as the sRGB gamut forces, until
   * it reaches 3:1, and its comment still writes the upstream value. Each is
   * named `--grafana-<shade>` and carries a comment `Grafana.<shade>
   * <upstream hex>` beside it, and
   * `every_grafana_colour_is_its_classic_shade_or_that_shade_darkened` holds
   * that the value is that hex, or that hex darkened and only where it had to
   * be. The shades were taken in Grafana's order, skipping any within
   * 0.10 (OKLab) of one already taken.
   *
   * This layer breaks the naming rule stated at the top of this file on
   * purpose, and the reason is the whole point of it: a token called
   * `--brandMagenta` is greppable against `tokens.dart` and a token called
   * `--sp-24` against the shop's `main.css`, and a token called `--accent` is
   * greppable against neither. **The palette layer is the record of where
   * every value came from.**
   *
   * The role layer below keeps the old rule intact: every role reads
   * `var(--<palette token>)` and nothing else, except where the two sources
   * genuinely do not reach — the state colours, the modal scrim, the two
   * font stacks, the reading measure, and the sub-14 px sizes a marketing page
   * has no need of. `every_role_token_comes_from_the_palette_except_these`
   * holds that, with the exceptions written out and their reasons beside them.
   *
   * A value used exactly once may name the palette from a rule rather than
   * minting a role for itself; minting a name for a single use produces the
   * "a name that means the other one" problem. Those references are deliberate
   * and the role scan cannot see them, so they are convention.
   *
   * Tokens declared here and used nowhere are listed by name in
   * `every_palette_token_is_referenced_or_listed_as_unused`, each with the
   * condition that would give it a home. The layer is the record, so a value
   * the interface does not happen to want is still worth carrying; what the
   * test stops is it becoming a dumping ground nobody reads. */

  /* Shape, from the customer app's `SsRadii` (#96). A radius paints and does
     not lay out, so moving these moves no box. `none`, `control` and
     `groupTop` are not carried: this interface has square corners by default,
     no switch, and no grouped list drawn as rounded rows. */
  --small: 8px; /* SsRadii.small */
  --tile: 16px; /* SsRadii.tile */
  --card: 24px; /* SsRadii.card */
  --pill: 999px; /* SsRadii.pill */

  /* Colour, from the customer app. Each value is spelled exactly as the
     `SsColors.sources` map beside the constant spells it, so the name and the
     value are both greppable there. The app's own palette carries forty; this
     is the subset a light web surface has a use for, and the app's
     frosted cards, photographic scrims and other ramps are not carried because
     this interface has no photograph and no dark card to put them on.
     Four of them are ramp stops and they belong to two different ramps.
     `--skyTop` and `--roseBottom` are `SsGradients.screen`, the app's default
     ground, and since #97 they are this interface's page ground too.
     `--creamTop` and `--sandBottom` are `SsGradients.splash`, which is no
     longer a ground here: the cream is a field's surface and the sand is the
     row a save's redirect lands on. */
  --ink: #000000; /* SsColors.ink */
  --inkStrong: #1E1E1E; /* SsColors.inkStrong */
  --inkSecondary: rgba(0,0,0,0.6); /* SsColors.inkSecondary */
  --onDark: #FFFFFF; /* SsColors.onDark */
  --brandMagenta: #E20074; /* SsColors.brandMagenta */
  --dotOutline: #D8D8D8; /* SsColors.dotOutline */
  --skyTop: #96A5B1; /* SsColors.skyTop */
  --roseBottom: #D7ABB4; /* SsColors.roseBottom */
  --creamTop: #F4F0E4; /* SsColors.creamTop */
  --sandBottom: #E9D9AA; /* SsColors.sandBottom */
  --statusConnected: #94EBC9; /* SsColors.statusConnected */
  --emberBottom: #E46C3C; /* SsColors.emberBottom */
  --helperText: #616161; /* SsColors.helperText */
  --buttonMutedFill: rgba(64,64,64,0.15); /* SsColors.buttonMutedFill */

  /* Colour, from Grafana's `Classic palette` in its light theme (#124), for
     the chart's lines. The comment on each line is the upstream shade and its
     upstream value; a value that differs from it was darkened to reach 3:1 on
     the white panel (see the header above). The classic palette's twelve
     named shades are not all carried: `dark-yellow` and `dark-orange` sit
     within 0.10 of a shade already taken. `dark-purple` is the tenth, taken
     for the two zone targets (#128), and 0.120 from `purple` in OKLab. */
  --grafana-green: #56A64B; /* Grafana.green #56A64B */
  --grafana-semi-dark-yellow: #B49105; /* Grafana.semi-dark-yellow #E0B400, darkened to 3:1 on the panel (#124) */
  --grafana-blue: #3274D9; /* Grafana.blue #3274D9 */
  --grafana-orange: #F06F01; /* Grafana.orange #FF780A, darkened to 3:1 on the panel (#124) */
  --grafana-red: #E02F44; /* Grafana.red #E02F44 */
  --grafana-purple: #A352CC; /* Grafana.purple #A352CC */
  --grafana-dark-green: #19730E; /* Grafana.dark-green #19730E */
  --grafana-dark-blue: #1250B0; /* Grafana.dark-blue #1250B0 */
  --grafana-dark-red: #AD0317; /* Grafana.dark-red #AD0317 */
  --grafana-dark-purple: #7C2EA3; /* Grafana.dark-purple #7C2EA3 */

  /* Space, and it is two ramps that meet at 40 px rather than one. The
     numeric ramp is a component scale; the T-shirt ramp is a page scale, and a
     marketing page's generosity lives entirely in the second. Note that the
     numeric ramp is NOT multiples of eight: it carries 12 and 48 and does not
     carry 20, 28 or 40. */
  --sp-8: .5rem;
  --sp-12: .75rem;
  --sp-16: 1rem;
  --sp-24: 1.5rem;
  --sp-32: 2rem;
  --sp-48: 3rem;
  --sp-64: 4rem;
  --sp-xs: 2.5rem;
  --sp-sm: 3.75rem;
  --sp-md: 5rem;
  --sp-lg: 7.5rem;
  --sp-xl: 10rem;
  --sp-xxl: 12.5rem;

  /* Type. The scale has no step below 14 px, because a marketing page has no
     table headings, no state pills and no meta lines. This interface has all
     three, and the pill is what sets a table row's height. */
  --text-sm: .875rem;
  --text: 1rem;
  --text-md: 1.25rem;
  --h6: 1.5rem;
  --h5: 1.75rem;
  --h4: 2rem;
  --h3: 2.25rem;
  --h2: 3.5rem;
  --h1: 4.75rem;
  --h0: 7.5rem;

  /* The page itself. */
  --container-width: 1408px;
  --box-shadow: 0px .25rem .625rem rgba(0, 0, 0, .05), 0px 2.125rem 1.25rem rgba(0, 0, 0, .03);

  /* === the roles ==========================================================
   *
   * What a value is *for*. Everything below either resolves into the palette
   * above or is a written exception. */

  /* --- type: the family ------------------------------------------------
   *
   * One family, and it falls back to the system **sans**, which is the
   * do-nothing outcome the whole appearance is designed to survive. There is
   * no display family since #96: `no_display_face_is_declared` holds that no
   * second family, no second `@font-face` and no serif comes back. */
  --ff: Inter, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;

  /* --- colour ----------------------------------------------------------
   *
   * The customer app's palette since #85, reached by name. What moved was the
   * palette; the roles below say the same things they said over the shop's.
   *
   * `--panel: var(--onDark)` reads oddly and is right: the app names its white
   * for where *it* uses white, over a photograph, and the palette layer keeps
   * the source's name rather than inventing a better one. The card is lighter
   * than the page, because the page is the app's sky-to-rose ramp and white
   * is the only thing that reads as a surface lifted off it. That is the
   * arrangement `--box-shadow` exists for, and since #97 it is doing visible
   * work: white reads 2.53 on `--skyTop` and 2.02 on `--roseBottom`, where
   * over the old cream-to-sand ground it read 1.14 and 1.40.
   *
   * **Every foreground here is measured against the surface it is drawn on**,
   * not against white by default, and
   * `text_roles_meet_aa_against_the_surface_they_sit_on` holds the pairings.
   * That is the lesson of #85's first plan, which measured the magenta as
   * text on white (4.68) and missed that `--accent` was then the page's top
   * stop and every button's fill, where grey headings came out at 3.03 and a
   * button's label at 3.42.
   *
   * So `--accent` letters nothing. Since #96 it **fills** the main action,
   * because the app's label on that fill is white, `--accent-label`, and
   * white on the magenta reads 4.68 where #85's dark label read 3.56. It no
   * longer rings anything: #97's darker ground took the magenta to 1.85 on
   * `--skyTop` and 2.31 on `--roseBottom`, and the ring moved to `--ink`.
   * What is left of the accent is that one fill -- 4.68 against a panel --
   * and `accent-color`. The fill is also drawn on the ground, by
   * `admin-accounts.html`'s *Add an account*, and there it is a written
   * exception rather than a measured pairing: WCAG 1.4.11 asks for 3:1 on
   * what identifies a control, and a filled button is identified by its own
   * label at 4.68 on the fill, not by its edge against the page.
   *
   * The thinnest margins in the file are the chart's lines, since #124 (the
   * chart section below has them): the yellow at 3.003 against 3.0 is 1.001
   * times the threshold, the orange 1.003 and the green 1.007. Among the text
   * pairs, ratio over threshold and not raw ratio, the thinnest is `--bad` on
   * `--target` at 4.655 against 4.5, 1.034 times, and the next is white on the
   * magenta at 4.681, 1.040 times -- and `--sandBottom`, which is no longer a
   * ramp stop, is the token to re-measure before touching the first of those. */
  /* The page ground: `SsGradients.screen`, the app's default ramp, top and
   * bottom -- the one its own doc comment gives to intro, consent, setup,
   * menu, hub, programmes and About. Two roles rather than `--accent` and a
   * base, because the ground is something every section heading is drawn on,
   * and a brand colour there is what failed in #85. It is darker than the
   * splash ramp it replaced in #97, which is the whole of that change: a
   * heading goes 14.63/11.88 to 6.60/8.24 and still passes, the focus ring
   * and a refusal line did not and are dealt with where they are drawn. */
  --ramp-top: var(--skyTop);
  --ramp-bottom: var(--roseBottom);
  --panel: var(--onDark);
  --field: var(--creamTop);
  --line: var(--dotOutline);
  /* A field's edge, which is a boundary and so a graphic at 3:1 on both of its
     sides -- the field's own cream and whatever the field sits in. The app's
     quieter-label grey reaches that everywhere a field is drawn: 6.19 on the
     white panel, 5.44 on `--creamTop` and 4.42 on `--sandBottom`, a row a
     redirect lands on (computed 2026-09-17; the graphic gate recomputes them). */
  --field-edge: var(--helperText);
  --text-colour: var(--inkStrong);
  --link: var(--ink);
  --accent: var(--brandMagenta);
  /* The main action's label, white on the magenta, as the app draws it. */
  --accent-label: var(--onDark);
  /* The muted button's fill, a translucent grey, so what it looks like is
     whatever it is composited onto: the contrast pairings measure the label
     on it over each surface a muted button sits on. */
  --button-muted-fill: var(--buttonMutedFill);
  --muted: var(--inkSecondary);
  /* The three state colours are **not** the brand's. The app supplies one
   * status colour, `--statusConnected`, and it is a dark-surface token: 1.40
   * on white, 1.80 on `--skyTop`, 1.44 on `--roseBottom`, 1.23 on `--creamTop`
   * and 1.00 on `--sandBottom`, so it cannot letter a pill or even outline one
   * here on any surface this interface has, and #85 left these three as they
   * were. They were chosen by eye as the same hues darkened until they read on
   * a white ground; since #85 the contrast pairings measure them against the
   * surfaces they are drawn on -- 5.01, 5.93 and 6.53 on a panel, and `--bad`
   * 4.66 on `--target` -- which is why a pill in one of these moved onto the
   * ramp would fail a test rather than a reader. On #97's ground they read
   * 1.98/2.47, 2.35/2.93 and 2.59/3.23, so that failure is now emphatic. */
  --ok: #1a7f4f; --warn: #8a5a00; --bad: #b4232b;
  /* The row a successful save's redirect lands on, in a white panel, so it
   * wants a colour visibly off white: the splash ramp's sand is the app's own
   * and needs no wash computing. It was the page ground until #97 and is not
   * one now, which changes nothing here -- a redirect's row is inside a panel,
   * so the ground never reached it. `color-mix()` works here and is refused on
   * purpose: an unparsed `color-mix` in a custom property fails at
   * computed-value time and the background falls back to nothing, which is the
   * defect this token already had once. What such a row carries -- a link, a
   * plain or muted pill, a field, an outlined or muted button, a Delete -- is
   * in the contrast
   * pairings, and `--ok` and `--warn` are not, because no row a redirect lands
   * on renders them: on this ground they would read 3.57 and 4.23. */
  --target: var(--sandBottom);
  --backdrop: rgba(0, 0, 0, .55);

  /* --- space -----------------------------------------------------------
   *
   * Ten of the fourteen roles below land on a step of the numeric ramp
   * exactly, so they come onto the scale at no cost at all -- which is worth
   * saying because the fleet list's budget forbids *moving* these values, not
   * naming them. Two round up (`--panel-pad-inline` and `--gap-wide`, 20 -> 24)
   * and both are horizontal only, spending 8 px of the table's available width
   * out of the 119 px the container width bought. Two sit below the ramp's
   * 8 px floor and stay off it. */
  --page-pad: var(--sp-24);
  /* The shop's own container width, now by name rather than as a literal.
     **Measured** at 1280 x 800 on a thirty-row German fleet with the
     *Simulated* badge on every row: at 1100 px the State cell's two pills stop
     sharing a line, the row goes 38.28 -> 60.06 px and `GET /` shows ten rows
     fully visible with 0.20 px behind them; at 1408 the pills share a line
     again and it shows fifteen with 24.11 px, which is the same headroom a
     fleet carrying no simulated device has. */
  --page-width: var(--container-width);
  /* The reading measure for the Working register's prose, and the unit is the
     point. A `ch` is the advance of the glyph `0`, which in this interface's
     own German came to about 1.4 characters in Figtree -- so `53ch` was **73
     to 75 German characters**, the generous end of the 45-75 convention,
     chosen because these blocks are one to three sentences qualifying
     something above them rather than continuous reading.
     **Measured 2026-09-01 in Figtree on the two engines the acceptance suite
     pins**, over 992 characters of the catalogue's own strings, because the
     ratio is NOT purely a property of the face and assuming it is would be
     wrong by two characters:

       Chromium 1228   zero 0.6250 em   average character 0.4548 em   1.374
       Firefox  1532   zero 0.6406 em   average character 0.4536 em   1.412

     **Re-measured 2026-09-17 under Inter (#96), the zero only**:

       size       12.8 px   13.6 px   16 px    64 px
       Chromium   0.6250    0.6618    0.6250   0.6094
       Firefox    0.6302    0.6311    0.6292   0.6133

     So `53ch` at 16 px is a 530.0 px box on Chromium, as it was, and 533.6 px
     on Firefox, down from 543. Inter's own `0` is 1292/2048 = 0.6309 em at its
     text cut; the 64 px figures are lower because the optical-size axis draws
     the display cut there, and Chromium's small-size figures are whole-pixel
     rounding, as they were in Figtree. **The average character was not
     re-measured under Inter**, so the 73-75 range and the per-size counts
     below are Figtree's and are stale by however much Inter's German differs.

     `ch` rather than `rem` because the blocks below sit at 12.8, 13.6 and
     16 px -- but not because one `ch` gives all three the same count. Measured
     in Figtree, it did on Firefox (75.8 / 75.3 / 75.5) and not on Chromium
     (73.4 / 78.9 / 73.6), where the rounding is size-dependent and 13.6 px
     lands outside the 45-75 convention; Chromium's 0.6618 at 13.6 px under
     Inter is the same rounding. `ch` still beats `rem`, which would give three
     counts on both engines instead of one. */
  --measure: 53ch;
  --panel-pad-block: var(--sp-16);
  /* 20 -> 24 px, and horizontal only. */
  --panel-pad-inline: var(--sp-24);
  --cell-pad-block: var(--sp-8);
  --cell-pad-inline: var(--sp-12);
  /* 6.4 px, below the ramp's 8 px floor. Rounding it up to --sp-8 would grow
     every input and select by 3.2 px to no end. */
  --control-pad-block: .4rem;
  --control-pad-inline: var(--sp-8);
  --button-pad-block: var(--sp-8);
  --button-pad-inline: var(--sp-16);
  /* Between the parts of one thing, between things, and between sections.
     `--gap-tight` is 4 px, below the ramp's floor, and it stays there. It has
     two jobs, not one: the gap between a label and its own field, inside one
     thing; and the gap `button + button` puts between two adjacent buttons,
     which is what stops the three step controls reading as a single lozenge
     with three glyphs in it. The second is by construction a gap *between* two
     things, so the value is not "inside one thing" -- it is the smallest
     separation this interface ever draws.
     `--gap` is 12 px and the ramp carries 12 exactly -- it is NOT multiples of
     eight -- so this value does not move in either direction, and the density
     question that would otherwise arise here does not. */
  --gap-tight: .25rem;
  --gap: var(--sp-12);
  /* 20 -> 24 px, and it appears solely as a column gap, so horizontal only. */
  --gap-wide: var(--sp-24);
  --stack-gap: var(--sp-16);
  --section-gap: var(--sp-32);

  /* --- type -------------------------------------------------------------
   *
   * **Space and type are `rem`; shape and measure are `px`.** That split is a
   * decision rather than a habit. A `rem` value tracks the reader's own font
   * size, so a body at `var(--text)` renders 15, 18.75 or 22.5 px for somebody
   * who has enlarged their text -- a `px` palette would freeze it at 16 and
   * take that away. A radius and a container width are shapes of the page and
   * do not want to grow with the text, so those stay in `px`; `--measure` is
   * `ch`, which is neither, and its own comment says why.
   *
   * The Working register's sub-body sizes are all below the scale's 14 px
   * floor and stay where they are, recorded as exceptions rather than silently
   * declined: a marketing page has no table headings, no state pills and no
   * meta lines, and this interface has all three. */
  --text-size: var(--text);
  --line-height: 1.35;
  /* A Working heading is the body size, set apart by weight and ink rather
     than by size. It was uppercased and tracked until #96, which is why it
     carried a tracking token; sentence case needs none. */
  --heading-size: var(--text);
  --th-size: .8rem;
  --th-tracking: .06em;
  --meta-size: .85rem;
  --small-size: .8rem;
  --pill-size: .75rem;

  /* --- shape ------------------------------------------------------------
   *
   * The app's own assignment (#96): a panel or a dialog is a card, a floating
   * menu is a card too (#193: a surface over the page, not a control), a button
   * is a pill, and a field is a small control. A state badge is a pill as well,
   * so a badge and a button are no longer told apart by their corners; what
   * tells them apart is that a button is a filled, outlined or muted control
   * at the button's padding and a badge is a small outlined label. Four roles
   * rather than one per radius, because each names a kind of box and a later
   * change may move one kind and not another.
   *
   * `.pill`'s box is what sets a table row's height, and a radius takes no
   * part in that: the corner changes and the box does not. */
  --radius-panel: var(--card);
  --radius-button: var(--pill);
  --radius-field: var(--small);
  --radius-pill: var(--pill);
  /* The programme list's artwork box (#169): the app's own thumbnail radius at
     80 dp (`ss_program_row.dart`), 16 px. Not `--radius-panel` (24 px), which
     is the dialog preview's and would round an 80 px box into a near-circle. */
  --radius-thumbnail: var(--tile);
  --border: 1px;

  /* --- the chart -------------------------------------------------------
   *
   * Declared last in the role layer, and that is positional rather than
   * tidy: `role_layer_starts_at` reads the palette/role boundary off
   * declaration order, so a chart token inside the palette run relabels
   * every palette token after it.
   *
   * The one element in this interface that paints itself. Its labels are
   * SVG text nodes and its lines are SVG paths, but neither is styled by
   * this sheet -- the charting library takes colours as option values, so
   * every name below is read by `chart.js` through `getComputedStyle` and
   * handed over. They are roles like every other name in this layer, and
   * they resolve into the palette like every other one, which is what keeps
   * the chart's colours inside the record. **A colour the script does not
   * hand over is one the library picks from its own palette**, which no gate
   * here measures -- so every line and every legend state has a name below.
   *
   * Colour names a line, and a pair shares one (#124). Every line in a plot
   * has a colour of its own except the two lines of a written pair -- a
   * pump's set level and measured speed, a remote board's air and
   * vibration, or the two zones' targets -- which share one role token and differ by dash pattern
   * (`two_lines_share_a_colour_only_as_a_written_pair` holds the list).
   * The colours are Grafana's `Classic palette`, taken in its order (see the
   * palette header above for the pin), and every one reaches the 3:1 a
   * graphic needs on the white panel: the yellow and the orange by being
   * darkened to it, so they are the thinnest graphic margins in the file, at
   * 3.003 and 3.009. The owner withdrew #86's ruling on 2026-10-12 that the
   * pattern alone lets a reader who cannot see colour tell every line apart:
   * colour-vision deficiency is not a design input here. The contrast
   * requirement stands.
   *
   * The temperature plot has ten colour groups: six single lines (flow,
   * return, boiler, gas, chiller air and the chiller's target), three board
   * pairs and the pair of zone targets (#128).
   * Board 3 and board 4 are **reserved** -- declared, resolved and measured
   * for contrast and distinctness, but no line in `chart.js` reads them until
   * #125 draws those boards. The legend's two colours are **text**, under the
   * 4.5:1 floor, the hidden entry's included: it is the one control that
   * brings a hidden line back, and the library's own pale grey for it is
   * 1.52:1. `--helperText` at 6.19 is the app's own word for a quieter label. */
  --chart-series-flow: var(--grafana-green);
  --chart-series-return: var(--grafana-semi-dark-yellow);
  --chart-series-boiler: var(--grafana-blue);
  --chart-series-gas: var(--grafana-orange);
  --chart-series-chiller-air: var(--grafana-red);
  --chart-series-target: var(--grafana-dark-green);
  /* The two zones' targets (#128) are one colour told apart by dash, like a
     remote board's air and vibration. */
  --chart-series-zone-target: var(--grafana-dark-purple);
  /* A remote board's air and vibration are one colour, told apart by dash.
     Board 2 is drawn today; boards 3 and 4 are reserved for #125. */
  --chart-series-remote-2: var(--grafana-purple);
  --chart-series-remote-3: var(--grafana-dark-blue);
  --chart-series-remote-4: var(--grafana-dark-red);
  /* The outputs plot (#117), by pump: one colour per pump, so a pump's set
   * level and its measured speed are compared at a glance, and the pattern says
   * which kind of line it is. Roles of their own and not the temperatures',
   * because a token named after flow painting a fan is a name that lies. The
   * heaters and the relay have a colour of their own each. */
  --chart-series-pump-a: var(--grafana-green);
  --chart-series-pump-b: var(--grafana-semi-dark-yellow);
  --chart-series-pump-c: var(--grafana-blue);
  --chart-series-heater-1: var(--grafana-orange);
  --chart-series-heater-2: var(--grafana-red);
  --chart-series-chiller-relay: var(--grafana-purple);
  --chart-legend-text: var(--inkStrong);
  --chart-legend-inactive: var(--helperText);
  --chart-grid: var(--dotOutline);
  --chart-label: var(--inkSecondary);
  --chart-tip: var(--onDark);
  --chart-tip-text: var(--inkStrong);
}
* { box-sizing: border-box; }
/* Beside `box-sizing` because it is the same kind of statement: this
   interface's sizes are the ones it declares. The UA stylesheet's
   `small { font-size: smaller }` took `device.html`'s dead-band hint, a
   <small> inside a `--small-size` label, to **10.6667 px on both engines** --
   the smallest text in the interface, at a size nobody chose and in no list of
   the sizes below the type scale's floor. */
small { font-size: inherit; }
/* The page is the customer app's screen ramp since #97, `SsGradients.screen`
   -- sky grey at the top to rose at the bottom -- and it is ONE RELATIONSHIP
   rather than three colours: both ends are role tokens reading the palette, so
   moving either moves the gradient and there is no third literal to drift. The
   two declarations below did not change when the ramp did, because they
   already read the roles.
   **The middle is not a value at all.** It is what sRGB interpolation between
   the two ends produces, so writing a third stop down would add a number that
   can only ever go wrong. And because every channel RISES monotonically from
   #96A5B1 to #D7ABB4 -- 150-165-177 to 215-171-180 -- so does the luminance: a
   foreground that meets its contrast against both ends meets it at every point
   between, which is why the contrast pairings test the two stops and nothing
   else. The splash ramp this replaced fell monotonically instead; what the
   argument needs is monotone, not a direction.
   `background-color` under the image is the bottom stop, so a page longer
   than the image paints on seamlessly, and a browser that cannot paint the
   gradient gets a ground every foreground is already measured against.
   `min-height: 100vh` is what stops the whole ramp compressing into a short
   Calm page's ~290 px of content and leaving everything below it flat rose.
   Measured, and the direction matters: what it prevents is a *squeezed whole
   gradient*, not a slice of the top. */
body {
  min-height: 100vh;
  margin: 0; color: var(--text-colour);
  /* The two tick boxes on a device page were the last blue in an interface
     `#334fb4` was removed from by name. On `body` rather than on the boxes,
     because the property inherits and the statement is about the interface.
     It touches only the CHECKED state; an unchecked box is a white square with
     a grey border on both engines either way.
     Both engines derive the mark from the accent's luminance rather than
     painting a fixed one, which was measured under the shop's pale gold
     (`#3B3B3B` on Chromium, `#55491D` on Firefox) and has not been re-measured
     under the magenta, which is dark enough for either engine to pick a white
     mark. A native control's own drawing, so it is outside the contrast
     pairings. */
  accent-color: var(--accent);
  background-color: var(--ramp-bottom);
  background-image: linear-gradient(180deg, var(--ramp-top) 0%, var(--ramp-bottom) 100%);
  background-repeat: no-repeat;
  font: var(--text-size)/var(--line-height) var(--ff);
}
/* The header takes the panel's own surface: a bar of the card colour, its
   bottom rule reads, and the ramp begins immediately below it. Everything in
   it -- its links, the `.meta` spans, three buttons and the zone
   picker -- is therefore measured against `--panel` and nowhere else. One
   declaration, no markup, and measured to cost no height. */
header {
  display: flex; align-items: baseline; gap: var(--stack-gap);
  padding: var(--panel-pad-block) var(--page-pad);
  background: var(--panel);
  border-bottom: var(--border) solid var(--line);
}
header a { color: var(--link); text-decoration: none; }
header .spacer { flex: 1; }
header .meta { color: var(--muted); font-size: var(--meta-size); }
/* The signed-in reader's address, and under it the language switcher, the zone
   picker and sign-out (#151). A `details`, as the chart's range menu is: it
   opens, acts and closes on a second click with no script.
   `margin-left: auto` keeps it at the right end of whichever line it lands on
   when the header wraps; without it a menu wrapped onto its own line starts at
   the left edge and its right-anchored dropdown overflows leftwards, where no
   scrollbar reaches. */
.account-menu { position: relative; margin-left: auto; min-width: 0; }
.account-menu > summary { display: flex; align-items: center; list-style: none; cursor: pointer;
                          border: var(--border) solid var(--button-muted-fill);
                          border-radius: var(--radius-button); }
.account-menu > summary::-webkit-details-marker { display: none; }
/* A long address is cut on a phone rather than widening the page. */
.account-menu > summary > span { min-width: 0; overflow: hidden; text-overflow: ellipsis;
                                 white-space: nowrap; }
.account-menu > summary::after { content: ""; flex: none; margin-left: var(--gap-tight);
                                 border-inline: .3em solid transparent;
                                 border-top: .35em solid currentColor; }
/* A click anywhere outside the dropdown closes the menu, with no script: the
   open summary draws a fixed overlay over the whole viewport, a click on a
   pseudo-element is dispatched to its element, and the summary toggles its
   `details` shut. The closing click does nothing else -- it does not follow
   the link or press the button it landed on.
   **Nothing on the open summary, or on `header`, may be any of these**, or
   the overlay stops working. They fail in two ways. `filter`, `transform` and
   `contain` make the box the containing block of a fixed descendant, so the
   overlay shrinks to that box and outside clicks stop closing the menu --
   which is why the hover rule below is `:not([open])`. `opacity` below 1,
   `isolation`, `mix-blend-mode` and a z-indexed `position` make a stacking
   context, so the overlay's z-index only counts inside it and positioned
   content such as the chart's canvas shows through and takes the click. */
.account-menu[open] > summary::before { content: ""; position: fixed; inset: 0; z-index: 2;
                                        cursor: default; }
/* Over the page, right-anchored under the address. z-index 3 is above the
   overlay's 2 and the range menu's 1. The tokens are `.range-dropdown`'s, so
   the box adds no contrast pairing, and so is the card radius: a pill here was
   scaled to `min(width, height) / 2` and the content stuck out of it (#193). */
.account-dropdown { position: absolute; z-index: 3; top: 100%; right: 0;
                    display: grid; gap: var(--sp-8); justify-items: start; width: max-content;
                    background: var(--panel); border: var(--border) solid var(--line);
                    border-radius: var(--radius-panel); box-shadow: var(--box-shadow);
                    padding: var(--sp-12); }
/* --- the two registers -------------------------------------------------
 *
 * Which one a screen is in is decided by one question -- **does it carry a
 * repeating row?** A <table>, or any list of records, is Working; at most one
 * form and no repeating rows is Calm. That is a question about the template
 * rather than a judgement, so it answers the register for every screen a
 * later issue adds without anybody deciding again.
 *
 * The class comes from `{% block register %}` in base.html, and **a template
 * that forgets it renders Working**, which is the direction that cannot
 * put a 32-56 px heading over a table. */
main { padding: var(--page-pad); max-width: var(--page-width); }
/* The Calm register is the one that takes the T-shirt ramp, because it is a
   page scale and this is the register with one column, no repeating row and no
   budget. `--sp-md` and `--sp-32` are named from the rule rather than through
   roles of their own: a name minted for a single use is the "a name that means
   the other one" problem, and the role scan cannot see a rule, so this is
   convention and is written down as such.
   The 34 rem column is #45's and does not move. Counted in characters rather
   than in `ch`, it is 62 today and 52.7 at the body size the type scale gives
   it -- both inside the 45-75 convention -- so there is nothing here for
   `--measure` to repair and widening seven screens would be a fresh appearance
   decision nobody asked for. */
main.calm {
  padding: var(--sp-md) var(--sp-32); max-width: 34rem; margin-inline: auto;
  font-size: var(--text-md); line-height: 1.6;
}
/* The one rule that sizes a Calm heading. It names no family: since #96 the
   heading is Inter like everything else, and it is the optical-size axis --
   not a second face -- that draws it in Inter's display cut at this size.
   Weight 400, the app's own weight for its `title` and `hero` roles; a UA
   `h2` is bold, which is the shop's register and not the app's. */
main.calm > h2 {
  /* The ceiling is the shop's own h2, which is the largest of the three sizes
     the shop set its display face at that fits a 34 rem column. The floor is
     today's floor to the pixel and at every root size, not only at 16 px --
     which matters, because a smaller floor renders 24 px at a true 390 px
     viewport against today's 32, an 8 px regression on a phone that no test
     here could see.
     `--h1` and `--h0` are declined and the arithmetic is the heading's own:
     the longest German Calm heading is 39 characters, and in a 480 px content
     box `--h1` bought 18 of them and `--h0` 11 in the display serif #96
     dropped. Inter at weight 400 is wider than that serif, and the owner
     accepted what that costs rather than shrink the heading (#96, 2026-09-17,
     "Accept the extra line"): measured at 1280 px on both pinned engines,
     *Anmeldung erforderlich* went from one line to two and *Dieser Link kann
     nicht verwendet werden* from two to three at this ceiling; at 390 px no
     Calm heading moved. No test pins those counts, by the same ruling. */
  font-size: clamp(var(--h4), 5vw, var(--h2));
  line-height: 1.1; letter-spacing: -0.01em; font-weight: 400;
  text-transform: none; color: var(--text-colour);
  margin: 0 0 var(--sp-xs);
}
/* A label follows its register, so the control inside it does too.
   The inherit chain on a Calm screen, innermost first, as both engines report
   it today:
       input          13.3333px   <- the UA's own `input { font: 400 13.3333px }`
       label          12.8px      <- `form.program label` below
       form.program   20px
       div.panel      20px
       main.calm      20px        <- `font-size: var(--text-md)` above
       body           16px
   The register is 20 px and it survives down to the <form>; the size is lost
   at the <label>, whose rule names a FORM CLASS and is therefore
   register-blind -- `form.program` is on Working screens and Calm ones alike,
   so one size has to serve both and 12.8 px is the Working answer. One line
   further down the same page, the sign-in button is **already 20 px**, because
   `button { font: inherit }` inherits through `div.actions` and a button is
   not inside a label. So the button followed its register and the field did
   not, and the only difference between them was which element they hang off.
   `inherit` rather than a token, so the label follows whatever the register
   sets and no role is minted for one consumer.
   **`main.calm form label` and not `main.calm label`**, which is not a matter
   of taste: (0,0,1,3) is strictly greater than `form.program label`'s (0,0,1,2)
   and so needs no help from document order -- verified by moving this rule to
   the end of the file and re-measuring identical figures. `main.calm label`
   would be an exact (0,0,1,2) tie decided by order, and written here it is
   **inert**: measured, the Calm label stays at 12.8 px on both engines because
   `form.program label` comes later. */
main.calm form label { font-size: inherit; }
/* A Working section heading, in sentence case since #96: the app never
   uppercases a heading, and an uppercase tracked label is the shop's register.
   It keeps the body size and its margins, because the fleet list's budget is
   measured above the table and ruling 2 of #96 keeps density. What the
   uppercase used to do -- tell a 16 px heading from the body text under it --
   is done by weight and ink instead, and ink only raises contrast on the ramp
   -- 6.60 on `--skyTop` and 8.24 on `--roseBottom`, where the muted grey reads
   4.03 and 4.46 and would FAIL AA at the top of the page. A heading is the one
   thing this interface letters straight on the ground: measured on 2026-09-18
   through every template in both languages, the only elements whose computed
   background resolves to `body` are `main`, an `h2`, and the `<p>` wrapping
   `admin-accounts.html`'s *Add an account*, and only the `h2` carries text. */
h2 { font-size: var(--heading-size); font-weight: 600;
     color: var(--text-colour); margin: var(--section-gap) 0 var(--gap); }
h2:first-child { margin-top: 0; }
/* The shadow is paint-only and takes part in no layout, which is measured
   rather than assumed: every variant of this sheet carrying it has the same
   row pitch, `tbody` top and document height as the variant without. It is
   what makes the card read as lifted off the warm ground now that the card is
   the *lighter* of the two. */
.panel { background: var(--panel); border: var(--border) solid var(--line);
         border-radius: var(--radius-panel);
         box-shadow: var(--box-shadow);
         padding: var(--panel-pad-block) var(--panel-pad-inline);
         margin-bottom: var(--stack-gap); }
table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }
th, td { text-align: left; padding: var(--cell-pad-block) var(--cell-pad-inline);
         border-bottom: var(--border) solid var(--line); }
th { color: var(--muted); font-weight: 500; font-size: var(--th-size);
     text-transform: uppercase; letter-spacing: var(--th-tracking); }
/* Which zone the times in this table are in, said once above row one. Left and
   not the UA default's centre, because every column under it is left-aligned;
   `--th-size` because a caption is the table's own heading and `th` is the
   nearest thing this sheet already has to one. */
caption { text-align: left; color: var(--muted); font-size: var(--th-size);
          padding: 0 0 var(--cell-pad-block); }
tr:last-child td { border-bottom: none; }
td.mono, .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9em; }
/* Not the accent: the accent is kept to the thing a reader presses and to the
   focus ring, so what makes a link a link here is the UA's own underline over
   an ink label. The
   masthead's links keep `text-decoration: none` and are distinguished from the
   muted spans beside them instead. */
a { color: var(--link); }
.pill { display: inline-block; padding: .1rem var(--control-pad-inline);
        border-radius: var(--radius-pill);
        font-size: var(--pill-size); border: var(--border) solid currentColor; }
.pill.on { color: var(--ok); } .pill.off { color: var(--muted); }
.pill.warn { color: var(--warn); } .pill.bad { color: var(--bad); }
/* Health, not online: `on` means the device is connected, and giving the
   health column the online column's class would make one handle mean two
   things. The state-bearing pills a row can carry more than one of also
   take a column class — `pill health warn`, `pill age warn` — so a browser
   journey addressing `.pill.warn` alone cannot match two cells and mean
   neither. */
.pill.ok { color: var(--ok); }
/* The simulated badge, which sits beside the online/offline pill in a State
   cell — so it takes a column class for the same reason `pill health warn` and
   `pill age warn` do: a journey addressing `.pill.off` must keep matching one
   element. Muted rather than alarming: a pretend device is not a fault. */
.pill.sim { color: var(--muted); }
/* #34: a device whose enabled loop follows no probe. Beside the online pill,
   and its own class for the same reason `sim` is; the warning colour, because
   the device is doing nothing until somebody acts — but not the fault colour,
   because nothing has failed. The probe page's pills each name what they
   mark, so no journey there has to tell two `.pill.off`s apart. */
.pill.setup { color: var(--warn); }
.pill.rising { color: var(--ok); }
.pill.gone, .pill.silent { color: var(--warn); }
.pill.assumed { color: var(--muted); }
/* #113: the old firmware's suggestion beside a probe. A hint, and what filling it
   in did, are information; a suggestion that cannot be taken is a warning, because
   the administrator has to act on it, but not a fault. Existing role tokens. */
.pill.hint, .pill.prefilled { color: var(--muted); }
/* Which board a fleet row's health is about, under the pill. Narrow on purpose:
   the fleet table's columns already take most of the page, and a sentence on one
   line would push it wider than the panel and wrap every heading. */
.health-detail { display: block; max-width: 8em; font-size: var(--pill-size);
                 color: var(--muted); }
.pill.duplicate, .pill.taken { color: var(--warn); }
tr.probe.rising td, tr.probe.disappeared td { font-weight: 600; }
/* Grouped rather than shared: `.panel dl.kv` and `form.config ...` are
   locators two acceptance journeys key on, so the owner-facing elements
   #16 adds carry their own class and borrow the layout here. `dl.bed` is
   #73's, and it is a third class for the same reason: `.panel dl.kv` must go
   on matching exactly one element, and on the owner's page none. */
dl.kv, dl.reading, dl.bed, dl.calibration { display: grid; grid-template-columns: max-content 1fr;
        gap: .35rem var(--gap-wide); margin: 0; }
dl.kv dt, dl.reading dt, dl.bed dt, dl.calibration dt { color: var(--muted); }
dl.kv dd, dl.reading dd, dl.bed dd, dl.calibration dd { margin: 0; }
/* The floor is what decides how many columns there are, and #58's wider page
   invalidated the old one: `auto-fit` gives a WIDER container NARROWER cells,
   so 210px resolved to five columns of 217.19 px at the 1408 px container, and
   the dead-band hint above read at 43.8 characters. Fixing the hint's size
   made that worse before it made it better. Measured at 1280 x 800 on both
   engines, German, **as a mean over full lines only** -- the last line of a
   wrapped paragraph is short by construction and says nothing about the
   measure:
       floor     columns  cell       hint                form.config height
       210px     5        217.19 px  43.8 chars, 6 lines  382.44 px
       size fix  5        217.19 px  34.3 chars, 8 lines  --
       16rem     4        277.50 px  45.0 chars, 6 lines  347.91 px
       18rem     3        378.00 px  65.7 chars, 4 lines  380.28 px
   So the form gets 35 px SHORTER, because the hint's own row loses two lines.
   14, 15 and 17rem all resolve to the same four columns at this width; 16 is
   the round number inside that band. `rem` rather than `px` because a column
   floor is how much *text* has to fit, so it should track a reader who has
   enlarged theirs; the file's "shape and measure are px" split is about radii
   and the container width.
   45.0 is the floor of the 45-75 convention rather than its middle, and
   nothing in this interface can give a hint the full `--measure` -- 53 ch is
   424 px at 12.8 px, which no auto-fit cell reaches without spanning. */
form.config, form.settings, form.identity, form.range {
              display: grid; grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
              gap: .85rem var(--gap-wide); margin-top: var(--stack-gap); }
form.config label, form.settings label, form.identity label, form.range label {
                    display: flex; flex-direction: column; gap: var(--gap-tight);
                    color: var(--muted); font-size: var(--small-size); }
form.config .row-check { flex-direction: row; align-items: center; gap: var(--control-pad-inline); }
form.config .actions, form.settings .actions, form.identity .actions {
                      grid-column: 1 / -1; }
/* THE field surface, and the exclusion list is the whole of the idea. A
   control a person types or chooses into is a box, and the honest way to name
   that set is to say what it is *not*: the enumerated side is closed and the
   styled side is open, so a type HTML adds later -- or a template adds
   tomorrow -- arrives styled rather than forgotten. #59 exists because it was
   the other way round. Five per-form rules named the kinds of field the
   screens they were written against happened to use, so an address box, a
   password box and every drop-down fell through to the browser's own
   appearance, square and white beside a rounded cream one in the same form.
   Two groups of exclusion, excluded for different reasons.
   NOT A BOX AT ALL: `hidden` renders nothing, and `submit`, `reset`, `button`
   and `image` are buttons -- the day one appears it wants the `button` rule's
   shape below, not a field's.
   A WIDGET THE BROWSER DRAWS: `checkbox`, `radio`, `range`, `color`, `file`.
   Their appearance is a control of their own, and colouring one of them is
   `accent-color` on `body` rather than a cream background here.
   An `<input>` carrying no `type` is a text field and it matches, because
   every arm is an attribute presence-and-value test that a missing attribute
   fails. There are none in the tree today and that is the safe direction if
   one appears.
   `font: inherit` and no size of its own, deliberately: the control takes its
   context, and what carries a register down to it is the *label* above it --
   see `main.calm form label`.
   Specificity is what makes one rule safe underneath every override the file
   already has. `input:not(...)` is (0,0,1,1), because `:not()` takes the
   specificity of its most specific argument; the bare `select` and `textarea`
   arms are (0,0,0,1). So `table.steps input` (0,0,1,2) and
   `form.program textarea` (0,0,1,2) keep overriding exactly what they
   overrode. A selector list inside `:not()` is Selectors 4 and both pinned
   engines answer `true` to
   `CSS.supports('selector(input:not([type=a], [type=b]))')`; `type` is ASCII
   case-insensitive in an attribute selector, which the rules replaced here
   already depended on.
   The edge is `--field-edge` and not the panel's `--line`, which reads 1.43
   against white: a cream field in a white panel was told apart by a hairline
   nobody could see (#96). */
input:not([type=checkbox], [type=radio], [type=range], [type=color], [type=file],
          [type=hidden], [type=submit], [type=reset], [type=button], [type=image]),
select,
textarea {
  background: var(--field); color: var(--text-colour); border: var(--border) solid var(--field-edge);
  border-radius: var(--radius-field);
  padding: var(--control-pad-block) var(--control-pad-inline); font: inherit;
}
/* No two buttons touch. `button` below once carried a gold fill and a border
   of the same gold, so two adjacent buttons read as one lozenge with two
   glyphs in it -- measured on a step row of `#d-new` without this rule, one
   background and one border colour on both engines, and two gaps of
   0 px on Chromium and -0.00006103515625 px on Firefox. The Firefox figure is
   negative: the boxes overlap by a sub-pixel hair rather than abutting, which
   is rounding and not a layout anything asked for. So the claim is that the
   controls touch, not that the gap is exactly zero, and the browser test
   asserts a gap greater than zero rather than a number -- which both engines
   fail without this rule, either way.
   The file already said this, scoped to `header form.language`, which is how
   the language switcher escaped the defect and the step editor did not; the
   invariant is stated once here instead.
   A margin rather than `display: flex; gap:` on the cell: the three step
   controls are siblings inside a <td> the script builds, and a second layout
   mode inside a table cell would fix that one place. */
button + button { margin-left: var(--gap-tight); }
/* The app's three button treatments, by the owner's rulings on 2026-09-17
   (#96), replacing #85's white button with a magenta outline.

   Which one a button takes is a rule, not a taste. Every form that edits or
   creates something has one MAIN action, the button that commits it, and it
   is FILLED: a magenta pill with a white label. Everything else is secondary.
   An action that undoes something without destroying it (Stop program,
   Unassign) is OUTLINED; an action that destroys is outlined in `--bad`; the
   rest -- an action on one row, a control that changes only what is shown, a
   second commit in a dialog whose main action is elsewhere, and the header's
   chrome -- is MUTED, a translucent grey pill. A bare `button` is filled, so a
   main action needs no markup and every secondary one names its class. A
   button nobody classified therefore arrives as a main action, which is the
   loud direction.

   Every treatment keeps the 1 px border, in its own fill's colour where it has
   a fill, so the box geometry is bit-identical to #85's and the fleet list's
   budget cannot move because of a button. The label weight stays 600; the
   app's is 400, and a heavier label on a narrower box is this interface's
   density rather than the phone's.

   The contrast is in the pairings: white on the magenta 4.68 whatever the
   button sits on; the magenta fill against the panel, the ramp and a
   redirect's row 4.68, 4.11 and 3.34. The app's 48 px minimum height is not
   taken, by the ruling that keeps this interface's density. */
button { background: var(--accent); color: var(--accent-label);
         border: var(--border) solid var(--accent);
         border-radius: var(--radius-button);
         padding: var(--button-pad-block) var(--button-pad-inline);
         font: inherit; font-weight: 600; cursor: pointer; }
/* Undo, not destroy. No fill, and the outline in the label's own ink, as the
   app draws its outlined button. */
button.outlined { background: transparent; color: var(--text-colour);
                  border-color: var(--text-colour); }
/* The muted pill. Its border is the fill's own translucent grey, and
   `background-clip: padding-box` is what makes that one colour: with the
   default `border-box` the fill would paint under the border too, and two
   layers of a 15% grey read as a darker ring the app does not draw.
   The fill is not a boundary at 3:1 against the surface, and it does not need
   to be: WCAG 1.4.11 asks for none on a control identified by its text label,
   and the app draws it the same way. It is a written hairline in the graphic
   pairings, with that reason, rather than left out.
   The step editor's up and down controls take it through the classes the
   script already mints, so the script is unchanged; the header's language,
   zone and sign-out buttons by the header's own forms, and the address
   button that holds them, because every one of
   them is chrome rather than a page's main action. The zone button is
   seen by a signed-out reader with no script, and by every signed-in reader
   once the account menu is open. */
button.muted, a.button.muted, .range-steps span.button.muted, .range-menu > summary,
header .account-menu > summary,
table.steps .step-up, table.steps .step-down,
header form.signout button, header form.language button, header form.timezone button {
  background: var(--button-muted-fill); background-clip: padding-box;
  color: var(--text-colour); border-color: var(--button-muted-fill); }
/* The destructive control stays outlined, and `background` is written out
   rather than inherited so that it cannot quietly pick the fill up again. A
   filled Delete would be the most prominent thing on the page.
   `table.steps .step-remove` is the same object built from script, so it takes
   the same arm rather than a second rule: `programs.html:227` appends the
   editor's remove control with a class and no `danger`, and giving it one
   there would make this the only part of #59 that touches markup. `table.steps`
   and `.step-error` are already script-minted classes this file names, so the
   coupling is not new; if a later reader prefers `danger` in the script, that
   edit is one string and this arm comes out with it. */
button.danger, table.steps .step-remove {
  background: transparent; color: var(--bad); border-color: var(--bad); }
/* Hover darkens whatever the treatment is rather than naming a new colour: a
   new hex would be a colour that is not the app's, and the palette scan
   forbids one. White on the darkened magenta only gains contrast. */
button:hover, a.button:hover, .range-menu > summary:hover { filter: brightness(.92); }
/* Not while open: a `filter` on the account summary would make it the
   containing block of its fixed overlay, and hit-testing the overlay is
   hovering the summary, so the overlay would shrink to the summary's own box
   at the first mouse movement. */
.account-menu:not([open]) > summary:hover { filter: brightness(.92); }
/* Keyboard focus, on everything a reader can press or type into. The ring
   sits 2 px OUTSIDE the control, on the surface around it, so it is a graphic
   against every surface a control can sit on -- and since #97 that is four
   surfaces, not three, because the page ground is dark enough to be its own
   case. In `--ink`: 21.00 on a panel, 14.97 on a redirect's row, 8.31 on
   `--skyTop` and 10.37 on `--roseBottom`, and 4.49 against the magenta fill of
   the button it rings, across the 2 px of surface the offset leaves between
   them. ONE ring colour everywhere rather than a ring that changes with what
   is under it: the magenta this replaced read 4.11 and 3.34 over the splash
   ramp and 1.85 and 2.31 over this one, so it was a ring that disappeared
   exactly where the only control outside a panel is drawn.
   **This is the one rule that names a palette COLOUR directly**, which is
   worth flagging because every other colour in this file reaches a rule
   through a role. Measured 2026-09-18: before this, no rule outside `:root`
   named a palette colour at all, though several name a size or a space step
   (`--sp-md`, `--h2`) for the reason the space section gives -- a role with
   one consumer is the "a name that means the other one" problem. The ring is
   the same case: it is the ink, there is no second thing `--focus-ring` could
   come to mean, and `--link` already spends the one honest role on `--ink`.
   The role scan reads no rules, so this is convention and is written down as
   such; `outline_and_graphic_roles_meet_non_text_contrast` reads this
   declaration and measures whatever colour it finds.
   `:focus-visible` rather than `:focus`, so a pointer click draws no ring. An
   outline takes part in no layout. */
button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible,
textarea:focus-visible, summary:focus-visible {
  outline: 2px solid var(--ink); outline-offset: 2px; }
.empty { color: var(--muted); padding: var(--cell-pad-block) 0; }
.saved { color: var(--muted); font-size: var(--meta-size); margin: 0 0 var(--stack-gap); }
/* The same line for a region of times that is not a table: a `dl` or a `p`.
   `.saved`'s shape, because both are a muted line qualifying what is under
   them, and `--small-size` rather than `--meta-size` because this one is
   repeated up to three times on a device page and should be the quieter of the
   two. */
.zone { color: var(--muted); font-size: var(--small-size); margin: 0 0 var(--stack-gap); }
/* "(typed)" beside a model an administrator wrote because the hub reported
   none (#170). `.zone`'s colour and size, because it sits in the same identity
   panel on the same surface, so its contrast is the one `.zone` already has
   there. A `span`, not a control. */
.typed { color: var(--muted); font-size: var(--small-size); }
/* An assumption stated under the table it qualifies. Its own class rather
   than `.saved`, which names the device page's saved-at line. */
.caveat { color: var(--muted); font-size: var(--small-size); margin: var(--gap) 0 0; }
/* A rejected save re-renders the form above this, so it has to be
   impossible to miss. */
/* `--radius-field` and not `--radius-panel`: a notice is a small box holding
   a sentence, not a card.
   The `background` is what makes "a refusal is readable" a property of THIS
   RULE rather than of where the templates happen to put a refusal. All seven
   today are inside a `.panel` or a `<dialog>`, both of which paint `--panel`,
   so this declaration changes no pixel -- verified 2026-09-18 by walking every
   rendered page in both languages for elements whose computed background
   resolves to `body`, and finding only `main`, an `h2` and the `<p>` around
   *Add an account*. What it buys is the day somebody writes a refusal outside
   a panel: `--warn` reads 5.93 on `--panel` and 2.35 on `--skyTop`, so on the
   page ground the sentence an admin most needs to read would be the least
   readable thing on it. */
.notice { color: var(--warn); background: var(--panel);
          border: var(--border) solid currentColor;
          border-radius: var(--radius-field);
          padding: var(--cell-pad-block) var(--cell-pad-inline);
          margin: 0 0 var(--stack-gap); }
/* One reading measure over every prose block in the Working register, whose
   column is as wide as the fleet table needs. Derived from the templates
   rather than remembered, and written as the operation rather than as a count
   because the corpus grows and a number here has no test under it: this
   selector is the *role* class each `<p>` in `assets/templates/` carries --
   the first of its classes, the rest being qualifiers like `fleet` or `range`
   that never appear here; `grep -rho '<p[^>]*class="[^"]*"'
   backend/assets/templates` prints the corpus -- less two, plus `<small>`.
   **The two left out are both identifiers rather than prose**, which is the
   only reason this rule accepts, and they are decisions rather than
   omissions: whoever re-derives this set from the grep will meet them and has
   to be told they were weighed. `mono`'s one paragraph is an activation URL,
   and is in the Calm register anyway. `.device-name` is a device's name as
   its owner typed it -- one line by construction, so the cap would be a rule
   with no case, and #60 left it out for `mono`'s reason rather than as an
   oversight. (Its own rule below says only why it is not an `<h2>`, which is
   a different question.)
   `<small>` is in none of that grep's output: the original derivation matched
   `<p ... class=` and was structurally blind to the element, a grep for the
   element is what found it, and adding it is what makes "every prose block in
   this register is capped" true for the first time. It is an upper bound that
   never binds inside an auto-fit cell (424 px against 277.50), and it is
   written down for the claim rather than for the effect.
   `p.meta` is scoped to the element because every other `.meta` in the tree is
   a `<span>` in the header, which keeps its own rule above.
   `.notice` comes out four characters shorter than the rest, and that is its
   padding rather than an error: 12 px each side and a 1 px border sit inside a
   border-box `max-width`, so 26 px of its 53 ch is chrome.
   **`main.calm` is deliberately not here.** The Calm column is 62 characters
   today and 52.7 at the body size a later commit gives it, both inside the
   convention, so it keeps the 34 rem it was given. */
.caveat, .notice, .empty, .saved, .zone, .step-error, p.meta, .temperature, small {
  max-width: var(--measure);
}

/* A device owner's own page: the device's name, and the one temperature.

   `.device-name` is not an `<h2>`. It was not one because this file
   uppercased those, and #96 ended that; it stays a `<p>` because the name is
   the panel's content rather than the title of a section, and an `<h2>` would
   take the section heading's size and margins, which are not the name's.

   The size is on `.temperature.live` and not on `.temperature`, because in two
   of its three states that element carries a whole sentence and a sentence set
   at 3.5 rem is not a decision anybody took. The base rule is the colour only,
   so those two render at the body's 16 px -- already one of the three sizes the
   reading-measure block above is derived over, which is why `.temperature`
   joining that selector moves no measurement. At `--h2` the cap simply never
   binds, the same upper bound that never binds the comment above already
   records for the auto-fit cell.

   Both name the palette from a rule rather than minting a role token. */
.device-name { font-size: var(--h4); margin: 0 0 var(--stack-gap); }
.temperature { color: var(--text-colour); margin: 0; }
.temperature.live { font-size: var(--h2); line-height: 1.1; }
.temperature.not-reporting, .temperature.no-reading { color: var(--muted); }

/* The HTML Standard's UA stylesheet already carries this rule, and the
   whole programs page rests on it. It is written out here because the
   editor script feature-tests for <dialog> precisely because it does not
   assume one: in a browser without <dialog> the element is unknown,
   `display: inline`, and nothing in the UA stylesheet hides a closed one —
   so /programs would render the table followed by every program's edit form
   and a live Delete button, and the fallback would be a no-op for the only
   failure it exists to handle. */
dialog:not([open]) { display: none; }
dialog { background: var(--panel); color: var(--text-colour);
         border: var(--border) solid var(--line);
         border-radius: var(--radius-panel);
         box-shadow: var(--box-shadow);
         padding: var(--panel-pad-block) var(--panel-pad-inline);
         max-width: 42rem; width: 90%; }
dialog::backdrop { background: var(--backdrop); }
/* A dialog's heading is a section heading, and takes `h2`'s treatment. */
dialog h3 { margin: 0 0 var(--gap); font-size: var(--heading-size); font-weight: 600;
            color: var(--text-colour); }
/* A programme's picture, inside its dialog. A height rather than a width,
   because the dialog is already `width: 90%` up to 42rem and the picture is
   whatever shape somebody uploaded: capping the height is what keeps a portrait
   photograph from pushing the dialog past its `max-height` and making it scroll
   internally. `width: auto` so the aspect ratio survives, and `--radius-field`
   rather than `--radius-panel` because this is a small box inside a panel, the
   same choice a notice makes. No colour, no new token, no `content:`. */
.picture img { display: block; max-height: 8rem; width: auto;
               border-radius: var(--radius-field); margin-bottom: var(--gap); }
/* An open non-modal dialog is out of flow: the UA stylesheet gives
   `dialog[open]` `position: absolute` with the inline insets at 0 and the
   block insets left `auto`, so it keeps its static block position, is
   centred horizontally, and floats over the table. Without script that is
   the only way a dialog is ever open, so it is put back in flow — scoped to
   `html:not(.js)`, because the blanket `dialog { position: static }` breaks
   showModal(), which needs that positioning to centre a top-layer dialog. */
html:not(.js) dialog[open] { position: static; margin: var(--stack-gap) 0; width: auto; max-width: none; }
/* The zone picker in the signed-out bar is for a reader with no script, and
   only for them: everybody else has their zone volunteered by their browser on
   their first page. The server cannot express that condition -- it does not
   know whether a reader has script -- so the marker set in <head> does, the
   same way the rule above uses it from the other side. Only the signed-out
   bar's picker is a direct child of `header`; the signed-in reader's is inside
   the account menu, where it is shown to everybody because the menu is the one
   place the issue lists it. The picker is in the markup unconditionally, so a
   reader whose stylesheet fails to load sees one they do not need, which is the
   safe direction. */
html.js header > form.timezone { display: none; }
/* The chart needs a height of its own, and this is the one place it can have
   one: ECharts sizes to its container, and a container with no height
   initialises to an <svg> that exists, has a bounding box and is one pixel
   tall. On the rule rather than in a `--chart-height` token, because
   `#chart`'s height is a shape rather than a role: a role token is a name a
   second consumer could take, and nothing else in this interface is a
   chart. */
#chart { height: 320px; }
/* With an outputs plot (#117) the container holds two plots, and the script
   splits whatever height it is given between them, so the height lives only
   here. An attribute selector, so no new class: the server sends
   `data-outputs` only when the window has an output reading. Its specificity,
   (1,1,0), beats the narrow-screen `#chart` below at every width, which is why
   that block has a `#chart[data-outputs]` rule of its own. */
#chart[data-outputs] { height: 560px; }
/* The no-script fallback is *hidden*, never removed: the sentence inside
   `#chart` stays in the document and the marker set in <head> decides whether
   anybody reads it, the same way the two rules above use it. Scoped to
   `.empty.noscript` rather than to `.empty`, because the panel's other `<p>`
   -- the one saying a range had no readings -- also carries `.empty`, and it
   is the sentence an administrator most needs to see. The two never coexist
   (a range with no readings renders no `#chart` at all), so this is belt and
   braces on a class name rather than on a position. */
html.js       #chart .empty.noscript { display: none; }
html:not(.js) #chart .empty.noscript { display: block; }
/* The row a successful save's redirect lands on. Works with no script. */
tr:target td { background: var(--target); }
/* A link that is a main action -- *New program*, *Add an account* -- and so
   the filled treatment, the same as `button`. */
a.button, .range-steps span.button {
           display: inline-block; background: var(--accent); color: var(--accent-label);
           border: var(--border) solid var(--accent);
           border-radius: var(--radius-button);
           padding: var(--button-pad-block) var(--button-pad-inline); font-weight: 600;
           text-decoration: none; }
/* The chart's range presets and step links (#112). They are links so they work
   with no script, and `a.button.muted` above is what keeps a row of nine from
   being nine filled pills: `a.button` alone is the main-action treatment, and
   #96 allows one main action per page. The current preset is underlined, which
   is a cue that needs no new colour and so no new contrast pairing. The class
   in the selector is what makes it (0,2,1) and so beat `a.button`'s
   `text-decoration: none` (0,1,1) whichever is written first. */
.range-presets, .range-steps { display: flex; flex-wrap: wrap; align-items: center;
                               gap: var(--gap-tight); margin-top: var(--stack-gap); }
/* The chart's range picker (#120): one button naming the range, which opens a
   `details` menu, and four drawn icons beside it, on one line at 390 px (the
   refresh icon is not in this row but on the zone line above it, #179). The
   summary and every icon are 34 px tall by construction (a 1 rem line or a
   1 rem icon box, plus the button's block padding and border), so "one line"
   is a property of the CSS and not of the alignment keyword. The icons are SVG
   and the caret is drawn with borders because the served font subset has none
   of those glyphs; the label is the one thing that is text. The label is
   weight 400 at the small size: the button shows a value, as a field does. */
.range-picker { display: flex; flex-wrap: nowrap; align-items: center; position: relative;
                gap: var(--gap-tight); margin-top: var(--stack-gap); }
.range-picker > .range-steps { flex: none; flex-wrap: nowrap; margin-top: 0; }
.range-menu { min-width: 0; }
.range-menu > summary { display: flex; align-items: center; list-style: none; cursor: pointer;
                        font-size: var(--small-size); font-weight: 400;
                        font-variant-numeric: tabular-nums; line-height: var(--sp-16);
                        border: var(--border) solid var(--button-muted-fill);
                        border-radius: var(--radius-button);
                        padding: var(--button-pad-block) var(--sp-8); }
.range-menu > summary::-webkit-details-marker { display: none; }
.range-menu > summary > span { min-width: 0; overflow: hidden; text-overflow: ellipsis;
                               white-space: nowrap; }
.range-menu > summary::after { content: ""; flex: none; margin-left: var(--gap-tight);
                               border-inline: .3em solid transparent;
                               border-top: .35em solid currentColor; }
/* Over the chart, as Grafana's does: in the flow it would widen the `details`
   and push the icons. The menu is not inside `#chart`, so no gesture meets it.
   A card and not a pill (#193): the button's 999px radius is scaled down to
   `min(width, height) / 2`, a circle on a phone, while the content sits in a
   12 px padded rectangle and sticks out of the painted outline. */
.range-dropdown { position: absolute; z-index: 1; top: 100%; left: 0; width: 36rem;
                  max-width: 100%; background: var(--panel); border: var(--border) solid var(--line);
                  border-radius: var(--radius-panel); box-shadow: var(--box-shadow);
                  padding: var(--sp-12); }
/* The shared `.range-presets, .range-steps` rule's 16px top margin separates
   the picker row from the chart; inside the menu it only pushed the presets
   down, so they started further from the top edge than from the side. The
   16px to the form is the form's own `margin-top`. */
.range-dropdown > .range-presets { margin-top: 0; }
/* The zone line and the refresh icon (#179). The base `.zone` rule is shared by
   nine other zone lines and is not edited: the scoped rule drops its bottom
   margin, which the row takes instead. */
.chart-head { display: flex; align-items: center;
              gap: var(--gap-tight); margin-bottom: var(--stack-gap); }
/* `margin-left: auto` and not `justify-content`: a scan reads any `content:` as
   a stylesheet-authored word, and that property's name contains one. */
.chart-head > .zone { margin: 0; }
.chart-head > .refresh { margin-left: auto; }
.range-steps .button.icon, .chart-head .button.icon { display: inline-flex;
                                                      padding: var(--button-pad-block) var(--gap-tight); }
.range-steps .icon svg, .chart-head .icon svg { inline-size: var(--sp-16); block-size: var(--sp-16); fill: none;
                         stroke: currentColor; stroke-width: 2; stroke-linecap: round;
                         stroke-linejoin: round; }
.range-steps span.button[aria-disabled="true"] { color: var(--muted); cursor: default; }
.range-presets span { color: var(--muted); font-size: var(--small-size);
                      margin-right: var(--gap-tight); }
a.button[aria-current="true"] { text-decoration: underline; }
form.program label { display: flex; flex-direction: column; gap: var(--gap-tight);
                     color: var(--muted); font-size: var(--small-size); margin-bottom: .85rem; }
form.program textarea { font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
                        min-height: 9rem; }
form.program .actions { display: flex; align-items: center; gap: var(--gap); }
/* A programme's gradient (#136): two previews and eleven controls. The previews
   are the app's two sizes -- the list's 80 px square and the carousel's 200 x
   160 tile -- from the spacing ramp (`--sp-md`, `--sp-xxl`, `--sp-xl`), so a
   preview and the tile it stands for are the same box. They are circles in
   Flutter's geometry and still: the movement lives in the app. A fieldset's
   default `min-width: min-content` would let a stop row push the dialog wider
   than a phone, so it is reset. */
form.program fieldset.gradient { border: 0; margin: 0 0 .85rem; padding: 0; min-width: 0; }
form.program fieldset.gradient legend { padding: 0; margin-bottom: var(--gap-tight);
                                        color: var(--muted); font-size: var(--small-size); }
form.program fieldset.gradient p { margin: 0; color: var(--muted); font-size: var(--small-size); }
.gradient-previews { display: flex; flex-wrap: wrap; align-items: flex-end; gap: var(--gap);
                     margin-bottom: var(--gap); }
.gradient-preview { flex: none; border-radius: var(--radius-panel); }
/* Pixels, not rem: the circle's radius is computed in pixels from the same
   80 x 80 and 200 x 160 (`data-width`/`data-height`, `gradient_css`), so box and
   circle scale together at any root font size. These are the app's dp. */
.gradient-preview.thumb { inline-size: 80px; block-size: 80px; }
.gradient-preview.tile { inline-size: 200px; block-size: 160px; }
/* The programme list's artwork (#169): the app's 80 px thumbnail. The gradient
   is the box's own background, so it shows while a picture loads, if it fails,
   and through a picture's transparent pixels; the picture covers it as the
   app's `BoxFit.cover` does. Every cell of a programme row is top-aligned (rule below),
   so a long step list does not leave the name, baseline, total and move buttons
   centred beside it, as a table cell is by default. */
.artwork-thumb { inline-size: 80px; block-size: 80px; border-radius: var(--radius-thumbnail);
                 overflow: hidden; }
.artwork-thumb img { display: block; inline-size: 100%; block-size: 100%; object-fit: cover; }
/* The steps of a programme, one numbered line each. The marker is drawn by the
   browser, so the count of steps is the count of items. Room for three digits
   (`100.`, the editor's cap is 100 steps) and tabular figures so the names line
   up. */
ol.step-names { margin: 0; padding-inline-start: 3.25ch; font-variant-numeric: tabular-nums; }
tr[id^="p-"] > td { vertical-align: top; }
form.program .gradient-stop { display: flex; flex-wrap: wrap; align-items: flex-end; gap: var(--gap); }
form.program .gradient-stop input[type=number] { width: 6rem; }
/* What the app will show for a programme, beside its picture form. */
.artwork-note { margin-bottom: var(--gap); color: var(--muted); font-size: var(--small-size); }
/* The script's step table, built from the textarea and never instead of it. */
/* What is the step editor's own, now that the field surface above supplies
   the rest: a narrow box for a number, and a tighter padding for a hundred-row
   table.
   **That padding has never applied.** `form.program input[type=text]` was
   (0,0,2,2) against this rule's (0,0,1,2), so a step input took `6.4px 8px`
   from the form rule rather than the `4.8px 6.4px` asked for here. With the
   form rule gone this one finally wins, and a step input goes 32.05 -> 28.86 px
   on Chromium and 32.08 -> 28.88 on Firefox. The inherited size is 12.8 px and
   not `form.program`'s 16: `programs.html:246` inserts the table into the steps
   <label>, so it inherits from `form.program label`. Measured 2026-09-02; no
   budget and no assertion reads it. */
table.steps input { width: 6rem; padding: .3rem var(--control-pad-block); }
table.steps button { padding: .2rem var(--control-pad-inline); font-weight: 400; }
/* A step is a wrapping flex line and not a table row (#127). It has five cells
   (two offsets, the duration, the controls, then both names) and a row of
   them is wider than a phone's dialog, so the cells wrap instead of the dialog
   scrolling sideways. The arithmetic, with each cell its width plus 1.5rem of
   padding and a 0.5rem gap:
     line one  upper 5.5 + legs 5.5 + duration 7.5 + controls 7.5 + 3 gaps 1.5
               = 27.5rem, which fits the 39rem content box at 1280 px and wraps
               at 375 px (18.1rem) and 320 px (15rem) without passing the edge;
     names     1.5 + 2 x 12 + 0.5 = 26rem side by side, where there is room, and
               one to a line where there is not. */
table.steps, table.steps tbody, table.steps tfoot { display: block; }
table.steps tr { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem;
                 padding-bottom: .5rem; }
table.steps td { padding: var(--cell-pad-block) .75rem; }
table.steps input.step-offset { width: 4rem; }
table.steps td.step-controls { white-space: nowrap; }
/* The names take a line of their own. Each input starts at 12rem and grows to
   fill its line, so two share a line when 26rem is free and one takes the whole
   line otherwise. `min-width: 0` is what lets a flex item shrink below its
   input's intrinsic width on a phone. */
table.steps td.step-names { flex: 1 1 100%; display: flex; flex-wrap: wrap; gap: .5rem; }
table.steps td.step-names input { flex: 1 1 12rem; min-width: 0; width: auto; }
/* Client-side validation is a convenience, never the enforcement: this is
   what the script says when it refuses to add a 101st row. */
.step-error { color: var(--bad); font-size: var(--meta-size); margin: var(--cell-pad-block) 0 0; }
/* The sign-out control is a form, not a link: signing out revokes a row,
   and no GET may change state. */
header form.signout, header form.language { display: inline; }
/* Chrome-sized, like the sign-out control beside them. **Measured**: at the
   default button padding the switcher's two buttons made the header 9.62 px
   taller, which took `GET /` from fifteen fully visible rows at 1280 x 800 to
   fourteen. Rule 3's budget is about the table, and it was spent above it. */
header form.signout button, header form.language button, header .account-menu > summary {
  padding: .2rem .55rem; font-weight: 400; font-size: var(--text-sm); }
/* The switcher's two buttons had `margin-left: .25rem` of their own here.
   `--gap-tight` is that same 4 px and `button + button` above now states it
   for every adjacent pair, so the spacing is unchanged and the invariant is
   no longer one form's private arrangement. */

/* --- the phone ---------------------------------------------------------
 *
 * Somebody checking their own machine does it from a phone, and there is no
 * script to help. Three changes and no fourth.
 *
 * **A wide table scrolls sideways rather than reflowing.** The idiomatic
 * CSS-only reflow puts each column's heading in front of its cell with
 * `td::before { content: "MAC" }`, which would put user-visible English inside
 * this file — a place neither the catalogue nor the template scanner would
 * ever look at, invisible to Ctrl+F and to a translator. That is the rule
 * `the_stylesheet_has_no_content_property_carrying_a_word` holds, and this
 * wrapper is what makes keeping it cost nothing.
 *
 * The wrapper carries no `tabindex`. A scroll container that is not focusable
 * cannot be scrolled by keyboard, which is an accessibility argument, and
 * accessibility is out of scope at the current state of the project by the
 * owner's decision. Touch scrolling — which is what the phone is about — needs
 * nothing. Recorded rather than dropped: `tabindex="0"` here is the correct
 * thing to add back the day that decision is revisited.
 *
 * Every locator that reaches into a table is a descendant selector, an id, or
 * a `:has(table)` filter, and `:has()` is itself a descendant relation — so
 * this wrapper is transparent to all of them. */
.scroll-x { overflow-x: auto; }

@media (max-width: 40rem) {
  /* Fluid page padding. `form.config`'s `repeat(auto-fit, minmax(16rem, 1fr))`
     already collapses to one column, so there is nothing else to do to a
     form. */
  :root { --page-pad: var(--sp-16); --panel-pad-inline: var(--sp-16); }
  main.calm { padding: var(--sp-32) var(--sp-16); }

  /* The header is a flex row that does not fit on a phone.
     **Measured at 390 px**: without this the address, the sign-out form and
     the language switcher run off the right edge and take the whole document
     with them, so the page scrolls sideways as a whole and the scroll wrapper
     below is beside the point. Wrapping keeps every control reachable, which
     is what hiding one of them would not. */
  header { flex-wrap: wrap; row-gap: var(--sp-8); }
  /* Never wider than the viewport, whatever the reader's address or zone. */
  .account-dropdown { max-width: calc(100vw - 2 * var(--page-pad)); }

  /* **Two columns, and which two follows from what the fleet list is for**:
     is it alive, is it healthy, and when did I last hear from it. Eight
     columns will not fit on a 390 px phone whatever is done to the type, and
     the wrapper above means the honest failure is a sideways scroll rather
     than a squeeze — so the only question is which columns a person sees
     without scrolling.

     `Firmware` is a version string nobody checks from a phone. `Last
     telemetry` goes and `Age` stays, which is not the same judgement twice:
     they say the same thing, and `Age` says it in four characters instead of
     nineteen. Keeping both would be keeping one fact twice at the width where
     width is the whole problem. */
  .col-secondary { display: none; }

  /* Measured at 390 px: 320 px of chart above a 20-row table pushes the
     readings off a phone screen entirely, and the chart is the thing that
     survives being smaller. On the rule for the same reason the 320 is, and
     inside this block rather than beside it because nothing else here is a
     custom property -- the `:root` above sets `--page-pad` for the whole
     document, so a token here would move the fleet list too. */
  #chart { height: 200px; }
  /* The two-plot chart on a phone (#117): 360 px, measured. At 340 px the
     outputs plot's level labels just clear one another and at 300 they overlap;
     360 px keeps the temperature plot at the 130 px a phone has today with 86 px
     for the outputs. It has to be a `#chart[data-outputs]` rule: the plain
     `#chart` above is outranked by the 560 px rule at every width, and this one
     has the same specificity and comes later, so source order decides. It costs
     160 px over the one-plot chart, which is the owner's trade. */
  #chart[data-outputs] { height: 360px; }
}
