/* Pluvio Cloud portal.
   Palette and type are lifted from pluviogrow.com so the portal reads as the
   same product, not a bolted-on admin tool. */

:root {
  --bg: #f4f1ec;
  --panel: #fbf9f6;
  --ink: #1a1a1a;
  --ink-soft: #6b6864;
  --rule: #d8d3cb;
  --orange: #c46f02;
  --orange-deep: #af6306;
  --water: #4a90c2;
  --good: #4c7a3f;
  --warn: #b8860b;
  --bad: #a33a2a;
  --dead: #b9b2a7;      /* dead volume: present, but never deliverable */

  /* ── THE TOKENS UNSCOPED PAGES FALL BACK TO (2026-09-23) ────────────────
   *
   * claim.html and login.html carry a bare <body> -- no `customer` class --
   * so every `body.customer` token is undefined on them and the FALLBACK in
   * each var() is what renders. Those fallbacks are dark-theme leftovers from
   * before Z7 converted the product to light: --panel2 fell back to #1b1e24
   * and .btn takes `color: inherit`, which on the light ground is #1a1a1a.
   *
   * NEAR-BLACK TEXT ON A NEAR-BLACK FILL, on the "Go to my Pluvio" button --
   * the first thing any new customer ever sees, and the first real one could
   * not read it.
   *
   * Z21 WAS THIS EXACT DEFECT IN A MEDIA QUERY: a dark block surviving the
   * light conversion because nothing resolved the scope it lived in. Here it
   * survived in the FALLBACK POSITION, which is worse, because a fallback is
   * invisible until a page forgets its class -- and the two pages that forgot
   * are the two a customer meets first.
   *
   * Defined at :root so a page cannot be unreadable for lacking a class.
   * body.customer still overrides them; this is the floor, not the palette. */
  --panel2: #efeae1;    /* a well inside a card */
  --line:   #d8d3cb;    /* the same rule as --rule, named as the base sheet
                           spells it -- both exist and both are used */
  --accent: #c46f02;    /* = --orange. The base sheet's name for it. */
}

/* ── THE PRODUCT IS LIGHT ONLY, AND THIS IS WHERE THE DARK THEME WAS ───────
 *
 * Z21, AND IT WAS LIVE ON GREG'S PHONE: a near-black page with near-black
 * text. Every garden name, the headline and every day figure invisible. Only
 * the state pills read, because they carry their own light backgrounds.
 *
 * WHAT WAS HERE: `@media (prefers-color-scheme: dark) { :root { --bg:#16150f;
 * --ink:#ece7dd; ... } }`. Z7 converted `body.customer` to the light palette
 * and this block survived at `:root`, so on a phone in dark mode
 *
 *     --bg    resolved DARK   (from this media query, at :root)
 *     --ink   resolved LIGHT  (#1a1a1a, from body.customer)
 *
 * -- two scopes disagreeing about the theme, with the ink from one painted on
 * the ground from the other. On a light-mode desktop they agree by accident,
 * which is why three of us spent a week looking at a page that worked.
 *
 * IT IS P0d INVERTED. That was light ink on a light ground; this is dark on
 * dark. Both times a pair resolved from two scopes and nothing asserted they
 * agree.
 *
 * THE BLOCK GOES BECAUSE THE BRAND HAS NO DARK THEME. website/index.html
 * carries no prefers-color-scheme at all -- that is why Z7 said light only,
 * and this block is what that ruling was meant to delete. A dark theme comes
 * back as a DECISION with a brand behind it, not as a survivor.
 *
 * tests/test_cascade_contrast.py now fails the build if any
 * prefers-color-scheme rule appears in this file, and its resolver handles the
 * scope -- proven by a positive control -- so if the decision is ever
 * reversed, the contrast check covers the dark path instead of skipping it.
 */

* { box-sizing: border-box; margin: 0; padding: 0; }

body {
  background: var(--bg);
  color: var(--ink);
  font-family: 'JetBrains Mono', ui-monospace, monospace;
  font-size: 13px;
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
  padding: 0 24px 48px;
}

/* ── Top bar ─────────────────────────────────────────────────────────────── */
.topbar {
  display: flex; justify-content: space-between; align-items: center;
  gap: 16px; flex-wrap: wrap;
  padding: 18px 0; margin-bottom: 20px;
  border-bottom: 1px solid var(--rule);
  font-size: 11px; letter-spacing: .08em; text-transform: uppercase;
  color: var(--ink-soft);
}
.topbar .left, .topbar .right { display: flex; gap: 12px; align-items: center; }
.mark { color: var(--orange); font-weight: 700; letter-spacing: .18em; }
.sep { color: var(--rule); }

.conn { display: inline-flex; align-items: center; gap: 6px; }
/* ONE .dot RULE, MERGED, for the same reason as .note above: a second
   declaration further down was quietly resizing every dot on the page from
   7px to .5rem, and 7px was never what rendered. */
.dot { width: .5rem; height: .5rem; border-radius: 50%;
       background: var(--ink-soft); display: inline-block; }
.conn-live .dot { background: var(--good); animation: pulse 2.4s ease-in-out infinite; }
.conn-init .dot { background: var(--warn); animation: pulse 1s ease-in-out infinite; }
.conn-down .dot { background: var(--bad); }
.conn-live { color: var(--good); }
.conn-down { color: var(--bad); }
@keyframes pulse { 0%,100% { opacity: 1 } 50% { opacity: .25 } }

/* ── Health tiles ────────────────────────────────────────────────────────── */
.tiles {
  display: grid; gap: 10px; margin-bottom: 20px;
  grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
}
.tile {
  background: var(--panel); border: 1px solid var(--rule); border-radius: 6px;
  padding: 10px 12px;
}
.tile .n { font-size: 22px; font-weight: 700; line-height: 1.15; }
.tile .k {
  font-size: 10px; letter-spacing: .07em; text-transform: uppercase;
  color: var(--ink-soft); margin-top: 2px;
}
.tile .sub { font-size: 10px; color: var(--ink-soft); margin-top: 3px; }
/* THE SECOND NUMBER (Z54): what this figure was a week ago, and the delta.
   DELIBERATELY UNCOLOURED. +2 offline is bad and +2 online is good, and a
   colour table mapping every count to a polarity is a second place for the
   meaning of a count to live and drift. The sign is the whole signal. */
.tile .was { font-size: 10px; color: var(--ink-soft); margin-top: 3px;
             font-variant-numeric: tabular-nums; }
.tiles-history { font-size: 11px; color: var(--ink-soft); margin: 0 0 6px; }
.tile.is-bad  { border-color: var(--bad); }
.tile.is-bad  .n { color: var(--bad); }
.tile.is-warn { border-color: var(--warn); }
.tile.is-warn .n { color: var(--warn); }
.tile.is-good .n { color: var(--good); }

.tiles-scope {
  font-size: 10.5px; color: var(--ink-soft);
  margin: -8px 0 10px; padding-left: 2px;
}

/* ── What the fleet can DO, led by the condition (Z64) ──────────────────────
   NOT .tiles WITH EXTRA PARTS. A tile is a number in a box and the whole point
   of this block is that the number was never the answer -- the units under it
   are. Full-width rows rather than an auto-fill grid, because a group holding
   eight names and a group holding one must not be forced into the same box:
   the size of the group is part of what the reader is here to see. */
.cond { display: grid; gap: 8px; margin-bottom: 20px; }
.cond-g {
  background: var(--panel); border: 1px solid var(--rule); border-radius: 6px;
  border-left-width: 3px; padding: 9px 12px;
}
.cond-h { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
.cond-n { font-size: 20px; font-weight: 700; line-height: 1.15;
          font-variant-numeric: tabular-nums; }
.cond-k {
  font-size: 10px; letter-spacing: .07em; text-transform: uppercase;
  color: var(--ink-soft);
}
/* The same UNCOLOURED week-ago figure .tile .was carries, for the same reason:
   +2 idle is nothing and +2 silent is the whole story, and a colour table
   mapping counts to polarity is a second home for that judgement. */
.cond-was { font-size: 10px; color: var(--ink-soft); margin-left: auto;
            font-variant-numeric: tabular-nums; }
.cond-why { font-size: 10.5px; color: var(--ink-soft); margin-top: 2px; }
.cond-u { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 7px; }
.cond-chip {
  font: inherit; font-size: 11px; cursor: pointer;
  background: var(--bg); color: var(--ink);
  border: 1px solid var(--rule); border-radius: 999px; padding: 2px 9px;
}
.cond-chip:hover { border-color: var(--ink-soft); }
.cond-none { font-size: 11px; color: var(--ink-soft); }
.cond-g.is-bad  { border-left-color: var(--bad); }
.cond-g.is-bad  .cond-n { color: var(--bad); }
.cond-g.is-warn { border-left-color: var(--warn); }
.cond-g.is-warn .cond-n { color: var(--warn); }
.cond-g.is-good { border-left-color: var(--good); }
.cond-g.is-good .cond-n { color: var(--good); }

/* ── Controls ────────────────────────────────────────────────────────────── */
.controls {
  display: flex; gap: 16px; align-items: center; flex-wrap: wrap;
  margin-bottom: 16px;
}
.field { display: flex; align-items: center; gap: 8px; }
.field > span, .check > span {
  font-size: 10px; letter-spacing: .07em; text-transform: uppercase;
  color: var(--ink-soft);
}
input[type=search], select {
  font: inherit; font-size: 12px; color: var(--ink);
  background: var(--panel); border: 1px solid var(--rule);
  border-radius: 4px; padding: 5px 8px;
}
input[type=search] { min-width: 210px; }
input[type=search]:focus-visible, select:focus-visible, button:focus-visible {
  outline: 2px solid var(--orange); outline-offset: 1px;
}
.check { display: flex; align-items: center; gap: 6px; cursor: pointer; }
.muted { color: var(--ink-soft); font-size: 11px; }

/* ── Device grid ─────────────────────────────────────────────────────────── */
.grid {
  display: grid; gap: 10px;
  grid-template-columns: repeat(auto-fill, minmax(268px, 1fr));
}
.card {
  background: var(--panel); border: 1px solid var(--rule); border-left-width: 3px;
  border-radius: 6px; padding: 11px 13px; cursor: pointer;
  display: flex; flex-direction: column; gap: 8px;
  text-align: left; font: inherit; color: inherit; width: 100%;
}
.card:hover { border-color: var(--orange); }
.card.s-online    { border-left-color: var(--good); }
.card.s-stale     { border-left-color: var(--warn); }
.card.s-offline   { border-left-color: var(--ink-soft); opacity: .78; }
.card.s-never_seen{ border-left-color: var(--rule); opacity: .6; }
.card.has-fault   { border-left-color: var(--bad); }

.card-head { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
.name { font-weight: 700; font-size: 13px; }
/* The device's folder. Muted: it is context for the name above it, never the
   headline -- and it is ALWAYS present (an ungrouped device reads
   "Ungrouped"), so it must not pull the eye on every card. */
.card .group { font-size: 11px; color: var(--ink-soft, #7a8691); margin-top: 2px; }
.state {
  font-size: 10px; letter-spacing: .07em; text-transform: uppercase;
  color: var(--ink-soft);
}

/* Reservoir bar. Usable water is the filled part; the dead-volume segment is
   drawn explicitly so it is visible as capacity that does not count. */
.res { display: flex; flex-direction: column; gap: 4px; }
.res-row { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
.res-ml { font-size: 15px; font-weight: 700; }
.res-ml .of { font-size: 11px; font-weight: 400; color: var(--ink-soft); }
.res-pct { font-size: 11px; color: var(--ink-soft); }
.bar {
  position: relative; height: 8px; border-radius: 4px;
  background: var(--rule); overflow: hidden; display: flex;
}
.bar .fill { background: var(--water); height: 100%; }
.bar .fill.low   { background: var(--warn); }
.bar .fill.empty { background: var(--bad); }
.bar .deadseg { background: var(--dead); height: 100%; flex: none; }
.bar.unknown { background: repeating-linear-gradient(
    45deg, var(--rule) 0 4px, transparent 4px 8px); }
/* CAPACITY NOT SET -- deliberately NOT the hatched "unknown" fill. Hatching
   says "no data", which reads as a fault in the unit; this says what is
   missing and is therefore a job somebody can pick up. Same box, different
   message, because they are different facts. */
.bar.unset {
  background: transparent; border: 1px dashed var(--rule);
  display: flex; align-items: center; justify-content: center;
}
.bar.unset .unset-label {
  font-size: 9.5px; letter-spacing: .04em; text-transform: uppercase;
  color: var(--ink-soft); white-space: nowrap;
}

.badges { display: flex; flex-wrap: wrap; gap: 5px; }
.badge {
  font-size: 9.5px; letter-spacing: .05em; text-transform: uppercase;
  padding: 2px 6px; border-radius: 3px; border: 1px solid var(--rule);
  color: var(--ink-soft); white-space: nowrap;
}
.badge.bad  { border-color: var(--bad);  color: var(--bad); }
.badge.warn { border-color: var(--warn); color: var(--warn); }
.badge.info { border-color: var(--water); color: var(--water); }
.badge.sim  { border-style: dashed; }

.meta {
  display: flex; flex-wrap: wrap; gap: 4px 14px;
  font-size: 10.5px; color: var(--ink-soft);
}

/* A null is rendered as an em dash, never as zero: "unknown" and "none" are
   different facts, and only one of them is safe to act on. */
.nul { color: var(--ink-soft); opacity: .65; }

/* ── Detail ──────────────────────────────────────────────────────────────── */
.detail { position: fixed; inset: 0; z-index: 20; display: flex; justify-content: flex-end; }
/* An author `display` beats the UA stylesheet's [hidden]{display:none}, so the
   panel must be hidden explicitly -- without this it renders permanently, as a
   blank sheet over half the fleet. */
.detail[hidden] { display: none; }
.detail-scrim { position: absolute; inset: 0; background: rgba(0,0,0,.35); }
.detail-panel {
  position: relative; width: min(660px, 100%); height: 100%; overflow-y: auto;
  background: var(--bg); border-left: 1px solid var(--rule); padding: 18px 22px 48px;
}
.detail-head {
  display: flex; justify-content: space-between; align-items: center;
  padding-bottom: 12px; margin-bottom: 14px; border-bottom: 1px solid var(--rule);
  position: sticky; top: -18px; background: var(--bg); padding-top: 18px; margin-top: -18px;
}
.detail-head h2 { font-size: 16px; }
.x { background: none; border: none; font-size: 22px; line-height: 1; color: var(--ink-soft); cursor: pointer; }

.sec { margin-bottom: 22px; }
.sec > h3 {
  font-size: 10px; letter-spacing: .09em; text-transform: uppercase;
  color: var(--ink-soft); margin-bottom: 8px;
  border-bottom: 1px solid var(--rule); padding-bottom: 5px;
}
.kv { display: grid; grid-template-columns: auto 1fr; gap: 4px 14px; font-size: 12px; }
.kv dt { color: var(--ink-soft); }
.kv dd { text-align: right; }

table { width: 100%; border-collapse: collapse; font-size: 11.5px; }
th, td { text-align: right; padding: 4px 6px; border-bottom: 1px solid var(--rule); }
th:first-child, td:first-child { text-align: left; }
th { color: var(--ink-soft); font-weight: 400; font-size: 10px;
     text-transform: uppercase; letter-spacing: .06em; }
tr.short td { color: var(--warn); }
.scroll-x { overflow-x: auto; }

.chart { width: 100%; height: 120px; display: block; }
.chart .line { fill: none; stroke: var(--water); stroke-width: 1.6; }
.chart .gap  { fill: none; stroke: var(--ink-soft); stroke-width: 1; stroke-dasharray: 2 3; opacity: .5; }
.chart .axis { stroke: var(--rule); stroke-width: 1; }
.chart text  { fill: var(--ink-soft); font-size: 9px; }

/* ONE .note RULE, MERGED. There were two, 60 lines apart, and the later one
   silently overrode padding and border-radius -- so the values written here
   were never the values used. Merged to what the browser was ALREADY
   rendering, so nothing moves on screen and the contradiction is gone.
   Same shape as P0f, without the consequences: a second rule for a class
   somebody else had already styled. */
.note {
  font-size: 11px; color: var(--ink-soft); border-left: 2px solid var(--warn);
  background: var(--panel);
  margin: 1rem 0 0; padding: .6rem .7rem; border-radius: 6px;
  border: 1px solid var(--line, #d8d3cb);
}
.note.bad { border-left-color: var(--bad); }

.win { display: flex; gap: 4px; }
.win button {
  font: inherit; font-size: 10px; text-transform: uppercase; letter-spacing: .06em;
  background: var(--panel); color: var(--ink-soft);
  border: 1px solid var(--rule); border-radius: 3px; padding: 3px 7px; cursor: pointer;
}
.win button[aria-pressed=true] { color: var(--orange); border-color: var(--orange); }

/* ONE .empty RULE, MERGED, same as .note and .dot: a later declaration was
   quietly changing the padding, so 36px was never what rendered. */
.empty { color: var(--ink-soft); padding: 4rem 1rem; text-align: center; }
code { background: var(--panel); border: 1px solid var(--rule); border-radius: 3px; padding: 1px 5px; }

.foot {
  margin-top: 32px; padding-top: 12px; border-top: 1px solid var(--rule);
  font-size: 10.5px; color: var(--ink-soft); max-width: 76ch;
}

@media (prefers-reduced-motion: reduce) { * { animation: none !important; } }

/* ── portal phase: auth, claiming, soil ──────────────────────────────────────
   Added for the customer-facing pages. Same tokens and same type scale as the
   fleet view above -- these are additional components, not a second theme.    */

.single { max-width: 34rem; margin: 2rem auto; padding: 0 1rem; }
.centered { text-align: center; }

.panel {
  background: var(--panel, #fbf9f6);
  border: 1px solid var(--line, #d8d3cb);
  border-radius: 10px;
  padding: 1.5rem;
  margin-bottom: 1rem;
}
.panel h1 { margin: 0 0 .5rem; font-size: 1.25rem; }

.field.block { display: block; margin: 1.1rem 0; }
.field.block > span { display: block; margin-bottom: .35rem; font-weight: 500; }
.field.block input {
  width: 100%; box-sizing: border-box;
  padding: .6rem .7rem; font: inherit;
  background: var(--bg, #f4f1ec); color: var(--ink, #1a1a1a);
  border: 1px solid var(--line, #d8d3cb); border-radius: 6px;
}
.field.block input:focus { outline: 2px solid var(--accent, #c46f02); outline-offset: 1px; }
.field.block small { display: block; margin-top: .35rem; }

.btn {
  display: inline-block; padding: .6rem 1rem;
  font: inherit; text-decoration: none; cursor: pointer;
  /* AN EXPLICIT COLOUR, NOT `inherit`. Inheriting made the button's contrast a
     property of whatever it happened to sit inside, so it was readable on one
     page and not on another with no rule changing. A control states its own
     pair. */
  background: var(--panel2, #efeae1); color: var(--ink, #1a1a1a);
  border: 1px solid var(--line, #d8d3cb); border-radius: 6px;
}
.btn.primary { background: var(--accent, #c46f02); border-color: var(--accent, #c46f02); color: #14100a; font-weight: 700; }
.btn:disabled { opacity: .5; cursor: default; }
.linkish {
  background: none; border: 0; padding: 0 .4rem; cursor: pointer;
  color: var(--muted, #8b93a1); font: inherit; text-decoration: underline;
}

.note.ok  { border-color: #2e6b3f; }
.note.bad { border-color: #7a2f2f; }
.note.warn, .muted.warn { color: #d69a3a; }

.tiers { border: 1px solid var(--line, #d8d3cb); border-radius: 8px;
         padding: .8rem 1rem 1rem; margin: 1.2rem 0 0; }
.tiers legend { padding: 0 .4rem; font-weight: 500; }
.tier { display: grid; grid-template-columns: auto 1fr; gap: 0 .6rem;
        align-items: baseline; padding: .45rem 0; cursor: pointer; }
.tier-name { font-weight: 700; }
.tier-desc { grid-column: 2; color: var(--muted, #8b93a1); font-size: .85em; }

.badge-admin {
  margin-left: .5rem; padding: .1rem .4rem; border-radius: 4px;
  background: var(--accent, #c46f02); color: #14100a;
  font-size: .7em; font-weight: 700; letter-spacing: .04em;
}

/* Soil gauges. A missing metric renders through cell(), so it arrives here as
   .nul and shows an em dash -- never a zero. See db/migrations/002_soil.sql for
   why null and zero are genuinely different measurements here. */
.gauges { display: grid; grid-template-columns: repeat(auto-fit, minmax(6.5rem, 1fr));
          gap: .6rem; margin: .6rem 0; }
.gauge { background: var(--bg, #f4f1ec); border: 1px solid var(--line, #d8d3cb);
         border-radius: 8px; padding: .6rem .7rem; }
.gauge-k { color: var(--muted, #8b93a1); font-size: .75em; text-transform: uppercase;
           letter-spacing: .05em; }
.gauge-v { font-size: 1.15rem; font-weight: 700; margin-top: .2rem; }
.mono { font-family: inherit; }
.gauge-at { margin-top: .25rem; font-size: .7em; color: var(--muted, #8b93a1); }
.tiny { font-size: .78em; }

/* Command status tones.
   `unknown` is deliberately NOT red. A command we could not confirm is not a
   failure -- this firmware refuses silently and a cycle during a bridge outage
   looks identical -- so it reads as neutral-uncertain, never as an error.
   `weak` is a completion inferred only from the watching flag, so it is a
   muted version of ok rather than the same green. */
.cmd-row     { border-top: 1px solid var(--line); padding: .6rem 0; }
.cmd-head    { display: flex; gap: .5rem; align-items: baseline; flex-wrap: wrap; }
.cmd-name    { font-family: ui-monospace, monospace; font-weight: 600; }
.cmd-means   { margin: .3rem 0 0; }
.cmd-badge   { font-size: .8rem; padding: .1rem .45rem; border-radius: .5rem;
               border: 1px solid currentColor; }
.tone-pending { color: #7a6a3a; }
.tone-ok      { color: #2c6e49; }
.tone-weak    { color: #6b7f5a; }
.tone-unknown { color: #5b6472; }
.tone-bad     { color: #8a3324; }

/* The device's effective value is the headline; a clamp is called out beside it
   rather than tucked into a tooltip. A customer who asked for 9999 mL and got
   500 is entitled to see both numbers and which one is real. */
.cmd-effective { margin: .3rem 0 0; }
.cmd-clamp     { color: #8a5a00; font-weight: 600; }


/* ── per-bucket config editor ────────────────────────────────────────────
   PHONE FIRST. This is used standing next to the unit, one-handed, so the rows
   stack rather than scroll sideways, the inputs are large enough to hit, and
   the outcome text sits under the control that produced it instead of in a
   toast that has gone by the time you look up. */
.bkt        { border-top: 1px solid var(--line); padding: .7rem 0; }
.bkt-head   { display: flex; gap: .6rem; align-items: baseline; margin-bottom: .4rem; }
.bkt-n      { font-weight: 600; }
.bkt-row    { display: flex; gap: .5rem; align-items: center;
              flex-wrap: wrap; margin: .35rem 0; }
.bkt-lab    { display: flex; gap: .4rem; align-items: center; flex: 1 1 12rem; }
.bkt-in     { flex: 1 1 5rem; min-width: 4.5rem; font-size: 1rem;
              padding: .5rem .55rem; border: 1px solid var(--line);
              border-radius: .4rem; }
.bkt-unit   { color: #6b7280; min-width: 1.7rem; }
.bkt-set    { font-size: 1rem; padding: .5rem .9rem; border-radius: .4rem;
              border: 1px solid var(--line); background: #f6f7f9;
              min-height: 2.6rem; min-width: 3.4rem; cursor: pointer; }
.bkt-set[disabled] { opacity: .55; cursor: default; }
/* The outcome takes the full width beneath the row it belongs to, so a clamp
   notice is never truncated into ambiguity on a narrow screen. */
.bkt-out    { flex: 1 0 100%; }
.bkt-state  { margin-top: .35rem; }

@media (max-width: 30rem) {
  .bkt-lab  { flex: 1 1 100%; }
  .bkt-set  { flex: 1 0 100%; }
}

/* ── device name ────────────────────────────────────────────────────────
   The id sits beside the name in a lighter, monospaced treatment: present for
   matching against a unit in your hand, visibly secondary to the name. */
.name-id  { font-family: ui-monospace, monospace; font-size: .78rem;
            color: #6b7280; margin-left: .45rem; }
.lbl-row  { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; }
.lbl-in   { flex: 1 1 12rem; min-width: 8rem; font-size: 1rem;
            padding: .5rem .55rem; border: 1px solid var(--line);
            border-radius: .4rem; }
.lbl-set  { font-size: 1rem; padding: .5rem .9rem; border-radius: .4rem;
            border: 1px solid var(--line); background: #f6f7f9;
            min-height: 2.6rem; cursor: pointer; }
.lbl-set[disabled] { opacity: .55; cursor: default; }
.lbl-out  { flex: 1 0 100%; }

@media (max-width: 30rem) {
  .lbl-in  { flex: 1 1 100%; }
  .lbl-set { flex: 1 0 100%; }
}

/* ── alert SMS panel (admin only) ────────────────────────────────────────
   The number is shown masked even here; the gate line is the most important
   text on the panel, because "sending is off" must never look like a fault. */
.sms-panel   { max-width: 46rem; margin: 1.5rem auto; padding: 0 1rem; }
.sms-row     { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap;
               margin: .5rem 0; }
.sms-in      { flex: 1 1 10rem; min-width: 7rem; font-size: 1rem;
               padding: .5rem .55rem; border: 1px solid var(--line);
               border-radius: .4rem; }
.sms-btn     { font-size: 1rem; padding: .5rem .9rem; border-radius: .4rem;
               border: 1px solid var(--line); background: #f6f7f9;
               min-height: 2.6rem; cursor: pointer; }
.sms-btn[disabled] { opacity: .55; cursor: default; }
.sms-consent { display: flex; gap: .5rem; align-items: flex-start;
               margin: .6rem 0; font-size: .9rem; color: #4b5563; }
.sms-out     { margin-top: .4rem; }

/* Away mode and the per-plant switch. The OFF state is dimmed rather than
   hidden: a bucket that will not be watered still has to be visible, or the
   customer cannot find it to turn it back on. */
.away-head  { display: flex; gap: .6rem; align-items: baseline;
              flex-wrap: wrap; margin-bottom: .35rem; }
.away-until { color: #6b7280; }
.away-home  { background: #fff; color: #374151; }
.bkt-en     { margin-bottom: .2rem; }
.bkt-en .bkt-lab { flex: 0 1 auto; gap: .5rem; cursor: pointer; }
/* 1.15rem: a checkbox at its default size is under the 44px touch target this
   page is built to, and this control is the one that stops a plant watering. */
.bkt-en input[type=checkbox] { width: 1.15rem; height: 1.15rem; }
.bkt-off    { opacity: .62; }
.bkt-off .bkt-set { opacity: .8; }

/* Inline rename on the fleet list. The pencil is deliberately low-contrast
   until hover/focus: seven of them down a column would otherwise read as the
   most important thing on the page, and the name is. */
.name-box   { display: inline-flex; align-items: baseline; gap: .35rem;
              min-width: 0; flex-wrap: wrap; }
.name-edit  { position: relative; border: 0; background: none; cursor: pointer;
              padding: 0 .25rem; font-size: .95rem; line-height: 1;
              color: var(--ink-soft, #7a8691); opacity: .55; border-radius: 3px; }
.card:hover .name-edit,
.name-edit:focus-visible { opacity: 1; color: var(--orange); }
/* A real touch target without a large glyph: the pseudo-element is positioned
   against the button and inset NEGATIVELY, so it extends the hit area ~12px in
   every direction while the pencil stays small. Both halves are required --
   position:relative on the button, and a negative inset here -- and an earlier
   draft of this rule had neither and did nothing at all. */
.name-edit::after { content: ''; position: absolute; inset: -12px; }
.name-editing { display: flex; gap: .35rem; align-items: center;
                flex-wrap: wrap; flex: 1 1 100%; }
.name-in    { flex: 1 1 8rem; min-width: 6rem; font: inherit; font-size: .9rem;
              padding: .35rem .4rem; border: 1px solid var(--orange);
              border-radius: 4px; }
.name-ok, .name-cancel {
              font: inherit; font-size: .8rem; padding: .35rem .6rem;
              border-radius: 4px; border: 1px solid var(--rule);
              background: var(--panel); cursor: pointer; }
.name-ok    { border-color: var(--orange); }
.name-ok[disabled], .name-cancel[disabled] { opacity: .55; cursor: default; }
.name-out   { flex: 1 1 100%; font-size: .78rem; }
.name-hint  { flex: 1 1 100%; font-size: .75rem; color: var(--ink-soft, #7a8691);
              line-height: 1.35; }
@media (max-width: 480px) {
  /* On a phone the controls get their own line rather than squeezing the box
     to nothing. */
  .name-in  { flex: 1 1 100%; }
}

/* ── soak panel ─────────────────────────────────────────────────────────────
 * SILENCE IS THE ONE STATE STYLED FIRST. A unit that goes quiet after an OTA
 * arrives as an absence, so it is given the loudest treatment on the page: a
 * filled badge carrying the WORD, a tinted row, and a left rule. Deliberately
 * not colour alone -- colour is the first thing lost to a screenshot, a
 * projector, or a reader who cannot separate red from grey, and this is the
 * row that must survive all three.                                           */
.soak-panel  { max-width: 78rem; margin: 2rem auto; padding: 0 1rem; }
.soak-head   { display: flex; gap: .75rem; align-items: baseline;
               flex-wrap: wrap; }
.soak-head h2 { margin: 0 .5rem 0 0; }
.soak-scroll { overflow-x: auto; }            /* the page never scrolls sideways */
.soak-drift  { font-size: 1.05rem; font-weight: 600; margin: .6rem 0 .2rem; }
.soak-drift-note { color: var(--ink-soft); margin: 0 0 .6rem; font-size: .85rem; }
/* Real drift is loud; a canary ahead of the fleet is not. The word carries it,
 * not the colour. */
.soak-drift-note.is-drift { color: var(--bad); font-weight: 700; }
.tile.is-canary { border-style: dashed; }
.soak-chip.is-canary { border-color: var(--water); color: var(--water); }

.soak-tbl    { width: 100%; border-collapse: collapse; font-size: .85rem;
               min-width: 62rem; }
.soak-tbl th { text-align: left; font-weight: 600; color: var(--ink-soft);
               border-bottom: 1px solid var(--rule); padding: .4rem .5rem;
               white-space: nowrap; }
.soak-tbl td { padding: .5rem .5rem; border-bottom: 1px solid var(--rule);
               vertical-align: top; }

.soak-row.is-silent { background: color-mix(in srgb, var(--bad) 9%, transparent);
                      box-shadow: inset 3px 0 0 0 var(--bad); }
.soak-row.is-never  { background: color-mix(in srgb, var(--ink-soft) 8%, transparent);
                      box-shadow: inset 3px 0 0 0 var(--ink-soft); }

.soak-name   { font-weight: 600; }
.soak-id     { color: var(--ink-soft); font-size: .75rem; }
.soak-ip     { font-variant-numeric: tabular-nums; color: var(--ink-soft); }

/* THE BUILD AND ITS DATE, always as a pair. soak-when is never absent. */
.soak-build  { display: flex; flex-direction: column; gap: .1rem; }
.soak-fw     { font-weight: 600; }
.soak-fw.is-memory { color: var(--ink-soft); font-style: italic; }
.soak-fw.is-stale  { color: var(--warn); }
.soak-fw.is-unknown { color: var(--ink-soft); font-weight: 400; }
.soak-when   { font-size: .72rem; color: var(--ink-soft); }
.soak-when.is-memory { color: var(--warn); }
.soak-when.is-stale  { color: var(--warn); }

.soak-clock  { display: flex; align-items: baseline; gap: .3rem; flex-wrap: wrap; }
.soak-n      { font-variant-numeric: tabular-nums; }
.soak-n.is-floor { color: var(--ink-soft); }
.soak-why    { display: inline-flex; align-items: center; justify-content: center;
               width: 1rem; height: 1rem; border-radius: 50%; cursor: help;
               border: 1px solid var(--rule); color: var(--ink-soft);
               font-size: .65rem; }
.soak-floor-note { font-size: .7rem; color: var(--ink-soft); width: 100%; }
.soak-watched { font-size: .7rem; color: var(--bad); width: 100%; }

.soak-badge  { display: inline-block; padding: .15rem .45rem; border-radius: .3rem;
               font-size: .72rem; font-weight: 700; letter-spacing: .02em; }
.soak-badge.is-silent  { background: var(--bad); color: #fff; }
.soak-badge.is-never   { background: var(--ink-soft); color: #fff; }
.soak-badge.is-faulted { border: 1px solid var(--warn); color: var(--warn); }
.soak-sub    { display: block; font-size: .7rem; color: var(--bad);
               margin-top: .15rem; }
.soak-clean  { color: var(--good); }

.soak-chip   { display: inline-block; margin-right: .25rem; padding: .05rem .35rem;
               border: 1px solid var(--rule); border-radius: .25rem;
               font-size: .7rem; color: var(--ink-soft); }
.soak-chip.is-bad { border-color: var(--bad); color: var(--bad); }
.soak-chip.is-warn { border-color: var(--warn); color: var(--warn); }
.soak-chip.is-ok  { border-color: var(--good); color: var(--good); }
.soak-chip.is-sim { border-style: dashed; }

.soak-export { margin-top: 1.25rem; }
.soak-export label { font-weight: 600; }
.soak-export textarea { width: 100%; font-family: inherit; font-size: .8rem;
                        padding: .5rem; border: 1px solid var(--line, var(--rule));
                        border-radius: .4rem; background: var(--panel);
                        color: var(--ink); }

/* THE DEVICE'S OWN UI, ON THE LAN. A link, explicitly labelled, because the
 * console cannot test whether this reader is on that network -- the probe would
 * be the same cross-origin fetch the browser blocks. Saying the condition beats
 * a link that silently hangs. */
.lan        { display: inline-flex; align-items: baseline; gap: .4rem;
              flex-wrap: wrap; }
.lan-a      { font-variant-numeric: tabular-nums; color: var(--water);
              text-decoration: underline; text-underline-offset: 2px; }
.lan-a:hover { color: var(--orange-deep); }
.lan-note   { font-size: .68rem; color: var(--ink-soft); white-space: nowrap; }
/* A LITERAL GLYPH, NEVER AN ESCAPE. Written as a CSS escape for this same
 * arrow, the backslash was consumed in transit and Python read what was
 * left as an OCTAL escape
 * -- so what shipped was U+0011 (an invisible control character) followed by
 * the literal characters 9 and 7. Every LAN link on three surfaces read
 * "...190 97 open on LAN", and reading the file as text showed nothing: only
 * the codepoints did. A character that survives being copied cannot be
 * mangled by whatever copies it. */
.lan-note::before { content: "↗ "; }   /* opens in a new tab */

/* ── FLEET CONSOLE ───────────────────────────────────────────────────────
 * THE PALETTE IS SCOPED TO #fleet AND NOTHING ELSE. The rest of the admin page
 * is the cream theme and stays that way: this is one dark panel inside it, not
 * a re-skin of the portal. Scoping it here is what makes that reversible -- the
 * tokens below shadow the sheet-level ones only inside this section, so nothing
 * outside can be changed by editing them.
 *
 * AMBER IS CHROME, NEVER STATE. --chrome is the brand: rules, headings, focus,
 * the frame. It NEVER carries a verdict. A warning is --warn, which is a
 * different colour with a different job, and if the two ever collapse the page
 * starts saying "look here" and "something is wrong" in the same ink. Asserted
 * by tests/chrome_not_state_test.mjs, which resolves every state class and
 * fails if any of them lands on a chrome token.
 *
 * STALENESS STILL RIDES FOUR CHANNELS and colour is only one: hatch, hollow,
 * dashed and a word, each of which survives greyscale, a projector and a reader
 * who cannot separate red from grey.                                          */
#fleet {
  /* ── LIGHT, LIKE THE REST OF THE PRODUCT ─────────────────────────────────
   *
   * THIS BLOCK USED TO BE THE DARK TERMINAL, and deleting the `--g-*`
   * namespace without changing it is what made every garden name invisible.
   * The namespace was not only a duplicate of the values: it was IMMUNE to
   * this override. `--g-ink` could not be reassigned here, so `.gardens`
   * always got its paper ink. Pointing everything at `--ink` handed the
   * gardens page this block's #F4F1EC on the paper #f4f1ec that `--bg` still
   * supplied -- the same hex, CONTRAST 1.00.
   *
   * I reported "there was never a second palette, only a duplicate to
   * delete". The VALUES were duplicates. The NAMESPACE was doing work, and I
   * removed the thing that was holding the two themes apart while leaving
   * both themes in place.
   *
   * Ops gets the same block for the same reason: a screenshot from either
   * side must read as one product, and it cannot if one of them is a dark
   * terminal.
   *
   * So the convergence finishes here rather than being half done: one
   * palette, the brand's, light. A customer no longer crosses a theme
   * boundary going from their grid into their own device page, and `--g-*`
   * does not need to come back to protect them from this block.
   *
   * MEASURED ON THE CASCADE, not on the tokens. tests/test_cascade_contrast.py
   * resolves what a rule actually sees through `:root` -> `body.customer` and
   * fails on any pair below 4.5. Comparing token values in isolation is what
   * let a same-hex pair ship.                                              */
  --ground:  #f4f1ec;   /* the site's paper */
  --field:   #fbf9f6;   /* a card on it */
  --field-2: #efeae1;   /* a well inside a card */
  --edge:    #d8d3cb;   /* the site's rule */
  /* CHROME. Structure and brand. Never a state. */
  --chrome:  #c46f02;
  --chrome-2:#af6306;
  --chrome-3:#8a8378;
  /* STATE. Verdicts only. Never structure. */
  --good:    #4c7a3f;
  --warn:    #8a682e;
  --crit:    #a33a2a;
  --idle:    #6b6864;
  --busy:    #4a90c2;
  --ink:     #1a1a1a;
  --ink-2:   #4a4742;
  --ink-3:   #6b6864;
  --r: 14px;
  --gap: 14px;
  /* The brand faces, from pluviogrow.com. Helvetica and Arial are system
     faces so nothing is fetched for them; the mono is the one webfont. */
  /* HELVETICA/ARIAL, NOT BARLOW. pluviogrow.com declares exactly two
     faces and Barlow is not one of them -- it was a third type system
     to go with the third palette. Read from the site rather than
     summarised: its own rules are Helvetica/Arial for display and
     JetBrains Mono for everything else. */
  --face: var(--face-display);
  --mono: "JetBrains Mono", ui-monospace, "Cascadia Mono", Consolas, monospace;

  max-width: 78rem;
  margin: 2rem auto;
  padding: 1.1rem 1.2rem 1.4rem;
  background: var(--ground);
  border: 1px solid var(--edge);
  border-radius: var(--r);
  color: var(--ink);
  font-family: var(--face);
}
#fleet h2, #fleet h3 { color: var(--ink); }
#fleet .muted        { color: var(--ink-3); }
#fleet .mono         { font-family: var(--mono); }
#fleet :focus-visible { outline: 2px solid var(--chrome); outline-offset: 2px; }

.fleet-head  { display: flex; gap: .75rem; align-items: baseline;
               flex-wrap: wrap; padding-bottom: .7rem;
               border-bottom: 1px solid var(--edge); }
.fleet-head h2 { margin: 0 .5rem 0 0; font-size: 1.05rem; letter-spacing: .02em;
                 text-transform: uppercase; }
/* The one place chrome touches a control: a frame, not a verdict. */
.fleet-head select { background: var(--field); color: var(--ink);
                     border: 1px solid var(--edge); border-radius: 6px;
                     padding: .2rem .35rem; font: inherit; font-size: .8rem; }
.fleet-head .field > span { color: var(--ink-3); font-size: .7rem;
                            text-transform: uppercase; letter-spacing: .04em; }

.fl-sum      { display: inline-flex; gap: .45rem; align-items: baseline;
               margin-right: auto; font-size: .82rem; color: var(--ink-2); }
.fl-sum-n    { font-size: 1.25rem; font-weight: 700; color: var(--ink);
               font-family: var(--mono); }
.fl-sum-bad  { color: var(--crit); font-weight: 600; }
.fl-sum-ok   { color: var(--good); }
/* .fl-note went with #fleet-note (Z17): it styled the "why the circle is
   not being drawn" line, and a refusal needs somebody to have asked. */
.fl-order    { font-size: .75rem; color: var(--ink-3); margin: .45rem 0; }

/* ── THE LEGEND: every explanation the tiles used to repeat, said once ──── */
.fl-legend   { display: flex; gap: 1rem; flex-wrap: wrap; align-items: center;
               margin: .7rem 0 .2rem; padding: .45rem .6rem;
               background: var(--field); border: 1px solid var(--edge);
               border-radius: 8px; font-size: .72rem; color: var(--ink-2); }
.fl-leg      { display: inline-flex; gap: .35rem; align-items: center; }
.fl-leg-svg  { display: block; overflow: visible; }
/* The watering swatch is a RING, matching the dial track that carries it --
   watering is a unit-level state, not a port one. */
.fl-leg-busy { fill: none; stroke: var(--busy); stroke-width: 2.4; }
.fl-leg-note { color: var(--ink-3); font-style: italic; margin-left: auto; }

.fleet-body  { margin-top: var(--gap); }

/* THE GRID. row and column are the grid with a fixed track count -- they are
   only ever drawn inside their range (fleet.js SHAPE_MAX), so neither needs an
   overflow rule to survive a count it will never be handed. */
.fl-grid     { display: grid; gap: var(--gap);
               grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); }
.fl-grid.is-row    { grid-auto-flow: column; grid-template-columns: none;
                     grid-auto-columns: minmax(11rem, 1fr); }
.fl-grid.is-column { grid-template-columns: minmax(14rem, 22rem); }

.fl-band     { margin-bottom: 1.25rem; }
.fl-band-h   { font-size: .72rem; text-transform: uppercase; letter-spacing: .06em;
               color: var(--ink-3); margin: 0 0 .5rem; font-weight: 600; }
.fl-band-h.is-toggle { background: none; border: 0; padding: 0; cursor: pointer;
                       font: inherit; color: var(--ink-2); }
.fl-strip    { display: flex; gap: .3rem; flex-wrap: wrap; }
.fl-dot      { width: .7rem; height: .7rem; border-radius: 50%;
               background: var(--good); }
.fl-dot.is-stale { background: none; border: 1px dashed var(--crit); }

/* ── THE CIRCLE ─────────────────────────────────────────────────────
 * ONE RING ONLY, up to CIRCLE_AUTO_MAX. Past that the grid takes over rather
 * than the ring shrinking. Concentric rings look better and read worse.       */
.fleet-body[data-mode="circle"] .fl-circle {
  position: relative; width: min(100%, 44rem); aspect-ratio: 1;
  margin: 0 auto;
}
.fl-circle .fl-pod {
  position: absolute; transform: translate(-50%, -50%);
  width: 9.5rem; text-align: center;
}
.fl-circle .fl-pod.is-solo { width: 20rem; }
.fl-hub      { position: absolute; left: 50%; top: 50%;
               transform: translate(-50%, -50%); text-align: center;
               pointer-events: none; }
.fl-hub-n    { font-size: 2.6rem; font-weight: 700; line-height: 1;
               font-family: var(--mono); color: var(--ink); }
.fl-hub-k    { font-size: .72rem; color: var(--ink-3); text-transform: uppercase;
               letter-spacing: .08em; }
.fl-hub-sub  { font-size: .75rem; color: var(--ink-2); margin-top: .25rem; }

/* ── POD and CARD: TWO RENDERERS OF ONE UNIT ─────────────────────────
 * Not one component at two sizes -- that is what put a 300 px card on a ring
 * and made seven of them collide. A pod has no frame, no rule and no padding:
 * it is a ring with a name under it. A card is the framed object.             */
.fl-pod      { display: flex; flex-direction: column; align-items: center;
               gap: .15rem; background: none; border: 0; }
.fl-pod-name { font-weight: 600; font-size: .82rem; line-height: 1.2;
               color: var(--ink); }
.fl-pod-sub  { font-size: .72rem; color: var(--ink-2);
               font-family: var(--mono); }
/* SEVERITY REACHES THE POD TOO. A card carries it on the left rule; a pod has
   no rule to carry it on, so without this a low tank in circle mode was visible
   only by reading all seven percentages -- the triage failure the whole page
   exists to prevent, reintroduced in the arrangement that has no attention band.
   Colour is NOT the only carrier: the sub-line is the number itself ("8%"), and
   anything critical also emits a chip with a word in it. */
.fl-pod.sev-warn .fl-pod-sub { color: var(--warn); }
.fl-pod.sev-crit .fl-pod-sub { color: var(--crit); font-weight: 600; }

.fl-card     { display: flex; flex-direction: column; gap: .5rem;
               background: var(--field); border: 1px solid var(--edge);
               border-left: 3px solid var(--edge);
               border-radius: var(--r); padding: .7rem .8rem; }
.fl-card:hover { background: var(--field-2); }

/* THE LEFT RULE IS THE SEVERITY, and it is never the only carrier: the state
   word beside the name says the same thing in text. */
.fl-card.sev-good { border-left-color: var(--good); }
.fl-card.sev-warn { border-left-color: var(--warn); }
.fl-card.sev-crit { border-left-color: var(--crit); }

/* CHANNEL 2: desaturated and hatched, on both renderers. */
.fl-pod.is-stale, .fl-card.is-stale {
  filter: saturate(.35);
  background-image: repeating-linear-gradient(45deg,
    rgba(255,255,255,.045) 0 4px, transparent 4px 9px);
}

.fl-head     { display: flex; justify-content: space-between; gap: .5rem;
               align-items: baseline; }
.fl-name     { font-weight: 700; font-size: .92rem; }
.fl-state    { font-size: .7rem; text-transform: lowercase; color: var(--ink-2); }
.fl-state.is-good { color: var(--good); }
.fl-state.is-warn { color: var(--warn); }
.fl-state.is-crit { color: var(--crit); }
/* A REMEMBERED WORD IS STRUCK THROUGH. "last said READY" on a unit silent for
   seventy minutes must not read like a live report. */
.fl-state.is-stale { text-decoration: line-through;
                     text-decoration-thickness: 1px; }

.fl-body     { display: flex; gap: .8rem; align-items: center; }
.fl-read     { display: flex; flex-direction: column; gap: .45rem; flex: 1;
               min-width: 0; }
.fl-k        { display: block; font-size: .64rem; text-transform: uppercase;
               letter-spacing: .05em; color: var(--ink-3); }
.fl-v        { display: block; font-size: .84rem; color: var(--ink);
               font-family: var(--mono); }
.fl-next.is-unknown .fl-v { font-style: italic; color: var(--ink-3);
                            font-family: var(--face); }
/* The frozen countdown is a sentence, not a number, so it wraps like one. */
.fl-next.is-stale .fl-v { font-family: var(--face); font-size: .74rem;
                          color: var(--ink-2); }

.fl-meter    { height: .4rem; margin-top: .2rem; border-radius: 3px;
               background: var(--field-2); border: 1px solid var(--edge);
               overflow: hidden; }
.fl-meter i  { display: block; height: 100%; background: var(--good); }
.fl-meter.is-low i   { background: var(--warn); }
.fl-meter.is-empty i { background: var(--crit); }
.fl-meter.is-unknown { border-style: dashed; }
/* CHANNEL 4: outline only. The number behind it is a memory. */
.fl-meter.is-stale i { background: none;
  background-image: repeating-linear-gradient(90deg,
    var(--ink-3) 0 2px, transparent 2px 5px); }

/* ── THE RING ─────────────────────────────────────────────────────── */
.fl-ring     { position: relative; align-self: center; }
.fl-ring-svg { display: block; overflow: visible; }
.fl-track    { fill: none; stroke: var(--chrome-3); stroke-width: 2; }
/* WATERING IS A UNIT-LEVEL STATE, not a port one: the cloud knows the unit is
   pouring (device_state.watering) and not which port, because `selected` is on
   the device's local API and not in the MQTT state publish. So the TRACK turns
   blue rather than a dot. */
.fl-ring.is-watering .fl-track { stroke: var(--busy); stroke-width: 2.6; }
.fl-track.is-stale { stroke: var(--ink-3); stroke-dasharray: 4 4; }

/* FOUR STATES. Hollow is "has water"; filled is "due"; ticked is "got less than
   it asked for"; and a dashed ghost is AN EMPTY PORT -- absence, not health. */
/* PRESENCE IS FILL. ABSENCE IS THE ABSENCE OF FILL.
 *
 * This ring drew FILLED = DUE and left everything else hollow, so a port with a
 * plant that simply was not thirsty and a port with NO PLANT IN IT were both
 * hollow circles, separated only by stroke colour (--ink-2 against --idle), a
 * 1.6-to-1.0 stroke width, a 1.5/2 dash and 75% opacity. At r=5.2 in a 100-unit
 * viewBox rendered at 116px -- about six pixels -- every one of those
 * differences is sub-perceptual. Rendered side by side at 1x and 2.4x, the
 * distinction is invisible at the first and obvious at the second, which is
 * exactly why it survived: it was only ever looked at in the legend, where the
 * swatch is large.
 *
 * A twelve-plant unit therefore read as an empty carousel, because ten of its
 * twelve plants were not due at that moment.
 *
 * So the mark carries PRESENCE, not urgency: a port with a plant is a SOLID
 * DISC, an empty port is a thin ring, and those are different SHAPES rather
 * than different shades. Urgency is then a colour change on a disc that is
 * already there, which is a difference the eye gets for free.
 *
 * The legend is drawn from these same classes, so it cannot describe a mark the
 * ring does not make. */
/* GREEN IS FINE, AMBER IS THIRSTY, RED IS WRONG -- the unit's own LED
   vocabulary. This ring shipped with GREEN meaning "needs water now", which is
   the most legible colour on the dial saying the opposite of what it looks
   like: a fully-planted unit with three thirsty plants read as three healthy
   ones among ten grey blanks. Due is a SCHEDULED ACTION and takes amber, not
   red -- the machine is about to do its job. */
.fl-pos          { fill: var(--good); stroke: none; }
.fl-pos.is-due   { fill: var(--warn); stroke: var(--warn); stroke-width: 1.6; }
.fl-pos.is-short { fill: var(--crit); stroke: var(--crit); stroke-width: 1.6; }
/* A STALE PORT IS THE UNKNOWN VERDICT, so it wears the unknown MARK (Z14).
   It was --ink-3, which is body grey -- an ink token doing a verdict's job,
   and it happened to be the same hex as --idle, so three names carried one
   value across two families. */
.fl-pos.is-stale { fill: none; stroke: var(--st-unknown-mark);
                   stroke-width: 1.4; stroke-dasharray: 2 2; }

/* --idle, NOT --chrome-3, AND Z14 HAS NOW REVERSED THE REASONING BUT NOT THE
   RULE. What this said was: the first draft reached for the muted brown that
   draws the ring track, on the reasoning that an empty port is "structural";
   it is not, it is a VERDICT about that port, and a verdict may not wear
   chrome. The test caught it before it shipped.

   GREG'S Z14 RULING SAYS THE FIRST DRAFT HAD THE CATEGORY RIGHT AND THE
   CONSEQUENCE WRONG. "An empty port is nearly furniture -- a reader who
   mistakes it for a faint rule has lost almost nothing, because an empty port
   IS an absence of content." The two mistakes do not cost the same, which is
   the whole argument: mistaking an empty port for a hairline loses nothing,
   and mistaking "we cannot see this unit" for a hairline loses the unit.

   THE RULE SURVIVES BECAUSE --idle IS NO LONGER A VERDICT TOKEN. It inks
   absence: an empty port here and an empty port on the customer strip. It sits
   deltaE 11.7 from --chrome-3 and is owed no distance. Every value that IS a
   verdict cleared 20 after Z14 -- measured, in chrome_not_state_test. */
/* THE EMPTY PORT: a thin open ring, and no fill at all. It is the only mark on
   the ring with a hole in it, which is what makes it findable at six pixels. */
/* THE EMPTY PORT: a thin open ring, and no fill at all. It is the only mark on
   the ring with a hole in it, which is what makes it findable at six pixels. */
.fl-pos.is-off   { fill: none; stroke: var(--idle); stroke-width: 1.2;
                   opacity: .8; }
.fl-centre   { font-size: 22px; font-weight: 700; fill: var(--ink);
               font-family: var(--mono); }
.fl-stale-band { position: absolute; left: 50%; top: 50%;
               transform: translate(-50%, -50%) rotate(-12deg);
               font-size: .62rem; font-weight: 700; letter-spacing: .06em;
               white-space: nowrap; padding: .05rem .3rem; color: var(--crit);
               background: var(--ground); border: 1px solid var(--crit);
               border-radius: 3px; }

/* ── CHIPS: only what DEVIATES ─────────────────────────────────────
 * There is no "ok" chip. A healthy sensor says nothing; seven tiles reading
 * "sensors ok" is seven statements that nothing happened.
 *
 * is-note IS NOT A WARNING. It is the canary: ahead on purpose. It takes --idle
 * rather than --warn, and NOT --chrome, because a state may never wear the
 * brand -- that is the rule this file is built around.                        */
.fl-chip     { display: inline-flex; align-items: center; font-size: .64rem;
               padding: .05rem .35rem; border-radius: 3px;
               border: 1px solid currentColor; font-family: var(--mono); }
.fl-chips    { display: flex; gap: .3rem; flex-wrap: wrap; }
.fl-chip.is-crit { color: var(--crit); }
.fl-chip.is-warn { color: var(--warn); }
.fl-chip.is-note { color: var(--idle); }

/* ── THE LAN LINK ─────────────────────────────────────────────────
 * The "open on LAN" caption is gone from the tile face -- seven copies of a
 * label for seven identical links. The arrow says it opens elsewhere and the
 * title attribute says where; the address itself is the link text.            */
.fl-open     { font-family: var(--mono); font-size: .7rem; color: var(--ink-2);
               text-decoration: none; display: inline-flex; gap: .3rem;
               align-items: baseline; }
.fl-open:hover { color: var(--chrome); text-decoration: underline; }
.fl-open::after { content: "↗"; color: var(--ink-3); }

/* ── THE OPERATOR STRIP ────────────────────────────────────────────
 * Where the id, build, uptime, last-seen and address live now. Folded away,
 * not deleted: taking a fact off the face of seven tiles is not the same as
 * losing it.                                                                  */
.fl-ops      { margin-top: var(--gap); background: var(--field);
               border: 1px solid var(--edge); border-radius: 8px;
               padding: .4rem .6rem; font-size: .74rem; color: var(--ink-2); }
.fl-ops-h    { cursor: pointer; color: var(--ink-3); font-size: .7rem;
               text-transform: uppercase; letter-spacing: .05em; }
.fl-ops-build { margin: .5rem 0; display: flex; gap: .4rem; flex-wrap: wrap;
                align-items: center; color: var(--ink); }
.fl-ops-t    { width: 100%; border-collapse: collapse; margin-top: .3rem; }
.fl-ops-t th { text-align: left; font-size: .64rem; text-transform: uppercase;
               letter-spacing: .05em; color: var(--ink-3); font-weight: 600;
               padding: .2rem .4rem; border-bottom: 1px solid var(--edge); }
.fl-ops-t td { padding: .2rem .4rem; border-bottom: 1px solid var(--field-2);
               white-space: nowrap; }
.fl-ops-t tr.is-stale td { color: var(--ink-3); }
.fl-ops-n    { color: var(--ink); font-weight: 600; }
.fl-sum-filter { color: var(--ink-3); font-style: italic; }


/* ── THE CUSTOMER CONSOLE ────────────────────────────────────────────────────
 * SCOPED TO body.customer, so admin.html keeps the cream theme and #fleet keeps
 * its own dark panel inside it. Three themes on one stylesheet sounds like two
 * too many; it is the honest description of what the product is -- an operator
 * console, an operator's fleet panel, and a customer's console -- and scoping
 * is what stops any of them changing the others by accident.
 *
 * BARLOW IS THE BODY FACE HERE AND NOWHERE ELSE. The customer console is a
 * different product for a different person and should not read like a terminal.
 * admin, claim and login keep JetBrains Mono. That divergence is a decision,
 * recorded, not a drift -- and it falls out of the module seam: these tokens
 * cannot reach a page that does not carry this class.
 *
 * AMBER IS CHROME, NEVER STATE. Water is --chrome because water is the product,
 * not a verdict. Every verdict takes a state token, and no state class may
 * resolve to a chrome token -- chrome_not_state_test.mjs resolves the whole
 * cascade and fails if one does.                                             */
body.customer {
  /* ── LIGHT, LIKE THE REST OF THE PRODUCT ─────────────────────────────────
   *
   * THIS BLOCK USED TO BE THE DARK TERMINAL, and deleting the `--g-*`
   * namespace without changing it is what made every garden name invisible.
   * The namespace was not only a duplicate of the values: it was IMMUNE to
   * this override. `--g-ink` could not be reassigned here, so `.gardens`
   * always got its paper ink. Pointing everything at `--ink` handed the
   * gardens page this block's #F4F1EC on the paper #f4f1ec that `--bg` still
   * supplied -- the same hex, CONTRAST 1.00.
   *
   * I reported "there was never a second palette, only a duplicate to
   * delete". The VALUES were duplicates. The NAMESPACE was doing work, and I
   * removed the thing that was holding the two themes apart while leaving
   * both themes in place.
   *
   * So the convergence finishes here rather than being half done: one
   * palette, the brand's, light. A customer no longer crosses a theme
   * boundary going from their grid into their own device page, and `--g-*`
   * does not need to come back to protect them from this block.
   *
   * MEASURED ON THE CASCADE, not on the tokens. tests/test_cascade_contrast.py
   * resolves what a rule actually sees through `:root` -> `body.customer` and
   * fails on any pair below 4.5. Comparing token values in isolation is what
   * let a same-hex pair ship.                                              */
  --ground:  #f4f1ec;   /* the site's paper */
  --field:   #fbf9f6;   /* a card on it */
  --field-2: #efeae1;   /* a well inside a card */
  --edge:    #d8d3cb;   /* the site's rule */
  /* CHROME. Structure and brand. Never a state. */
  --chrome:  #c46f02;
  --chrome-2:#af6306;
  --chrome-3:#8a8378;
  /* STATE. Verdicts only. Never structure. */
  --good:    #4c7a3f;
  --warn:    #8a682e;
  --crit:    #a33a2a;
  --idle:    #6b6864;
  --busy:    #4a90c2;
  --ink:     #1a1a1a;
  --ink-2:   #4a4742;
  --ink-3:   #6b6864;
  --r: 14px;
  /* HELVETICA/ARIAL, NOT BARLOW. pluviogrow.com declares exactly two
     faces and Barlow is not one of them -- it was a third type system
     to go with the third palette. Read from the site rather than
     summarised: its own rules are Helvetica/Arial for display and
     JetBrains Mono for everything else. */
  --face: var(--face-display);
  --mono: "JetBrains Mono", ui-monospace, Consolas, monospace;

  background: var(--ground);
  color: var(--ink);
  font-family: var(--face);
  font-size: 15px;
  padding: 0 24px 64px;
}
body.customer .topbar { border-bottom-color: var(--edge); color: var(--ink-3); }
body.customer .mark   { color: var(--chrome); }
body.customer .who    { color: var(--ink-2); letter-spacing: .08em; }
body.customer .muted  { color: var(--ink-3); }
body.customer .linkish { color: var(--ink-3); background: none; border: 0;
                         font: inherit; cursor: pointer; }
body.customer .linkish:hover { color: var(--ink); }
body.customer :focus-visible { outline: 2px solid var(--chrome);
                               outline-offset: 3px; }
body.customer .conn-live { color: var(--good); }
body.customer .conn-down { color: var(--crit); }

.tabs { display: inline-flex; border: 1px solid var(--edge); border-radius: 8px;
        overflow: hidden; }
/* AN AUTHOR `display` BEATS THE UA STYLESHEET'S [hidden]{display:none}, so the
   toggle must be hidden explicitly. Without this it renders permanently and a
   customer with ONE Pluvio is offered a Fleet tab to a page showing the same
   single dial -- the exact control the design says must not exist.
   This file already carries the identical note for .detail[hidden]; the bug was
   reintroduced three hundred lines below its own warning, and found by looking
   at a screenshot rather than by reading the code. */
.tabs[hidden] { display: none; }
.tabs button { font: inherit; font-size: .72rem; letter-spacing: .08em;
               text-transform: uppercase; padding: .3rem .7rem; cursor: pointer;
               background: none; border: 0; color: var(--ink-3); }
.tabs button[aria-pressed="true"] { background: var(--field-2); color: var(--ink); }

.console { max-width: 74rem; margin: 1.5rem auto 0; }

/* THE ACCOUNT BLOCK, under whatever screen is up. Same column as the
 * console so it reads as part of the page rather than a footer, and a
 * rule above it because it is about the ACCOUNT and not about the
 * garden it happens to sit beneath.
 *
 * `--edge`, WHICH RESOLVES THROUGH body.customer -- the same token
 * .device-remove uses twenty lines from the `.txt-*` rules this frames.
 * `--rule` at :root would also resolve and would look identical today;
 * two tokens for one line is how they stop being identical.
 *
 * EMPTY UNTIL THE COPY IS FETCHED, so the border must not draw on an
 * empty box -- a rule across the page under nothing is a section that
 * failed to load, announced as a design. */
.account { max-width: 74rem; margin: 0 auto; }
.account:not(:empty) { margin-top: 2.5rem; padding-top: 1.5rem;
                       border-top: 1px solid var(--edge); }

/* ── THE DIAL ────────────────────────────────────────────────────────────────
 * One unit is one dial, never a card containing a dial. A card wrapping a dial
 * is what let a bad number hide among eight good ones.
 *
 * Z13: THE DIAL ITSELF IS DRAWN BY gardens.js AND STYLED UNDER .g-dial, on
 * both pages. What is left here is the WRAPPER the device page needs so the
 * plant popover has something to be positioned inside -- `position: relative`
 * and nothing else. Everything that used to be in this block moved:
 *
 *   .dial-body    -> .g-dial .body
 *   .dial-water   -> .g-dial .water   (still --water, still the P0d fix)
 *   .dial-centre  -> .g-dial .centre  (SVG text, so it scales with the dial
 *                    instead of needing a rem size per surface)
 *   .dial-alert   -> DELETED; the centre figure wears the verdict now
 *   .port.is-*    -> .g-dial .slot .* ; the dots are icons and the states are
 *                    shapes. See gardens.js for why amber alone was not it.
 */
.dial      { position: relative; display: inline-flex; }

/* ── FLEET SCREEN ───────────────────────────────────────────────────────── */
.fleet-line { display: flex; gap: .45rem; align-items: baseline;
              margin-bottom: 1.5rem; color: var(--ink-2); font-size: .95rem; }
.fleet-need { font-weight: 700; color: var(--ink); font-family: var(--mono); }
.dot.is-crit { background: var(--crit); }
.dot.is-good { background: var(--good); }

/* THE UNIT TILE IS GONE (Z13). console.js's unitTile() drew a second garden
   card -- dial, name, word -- for a screen that has rendered gardens.js since
   Z7, so the CSS was styling a component with no caller. Deleted with it
   rather than left behind, because dead paint is how a deleted component comes
   back. .device-word.is-invite survives below and is the one that carries the
   "an invitation IS chrome" reasoning. */
.device-dial   { text-align: center; }
/* A NAME IS NOT A STATE. "pod" was rendering in the watering blue, which
   reads as a verdict about the unit rather than as what it is called -- and it
   would have changed colour when the machine did. Names take ink. */
.device-name   { margin: 1rem 0 .2rem; font-size: 1.5rem; color: var(--ink); }
.device-word   { font-size: .8rem; letter-spacing: .06em; text-transform: uppercase; }
.device-word.is-good  { color: var(--ink-3); }
/* "NOT RESPONDING" IS A VERDICT, AND IT WAS WEARING AN INK TOKEN (Z14).
   --ink-3 is body grey; it happens to be #6b6864, which is also what --idle
   was, so three names carried one value across two families and the one
   sentence on this page that means "we cannot see this unit" was painted with
   the same grey as a caption. It takes the unknown state's TEXT value now,
   like the other three words beside it. */
.device-word.is-idle  { color: var(--st-unknown-text); }
.device-word.is-invite{ color: var(--chrome); }
.device-word.is-warn  { color: var(--warn); }
.device-word.is-crit  { color: var(--crit); }
.device-detail { color: var(--ink-3); font-size: .85rem; margin-top: .3rem; }
/* A unit we cannot hear: everything on the right is a remembered reading. */
.device.is-silent .device-facts { opacity: .72; }

.rows { display: flex; flex-direction: column; }
.row  { display: flex; justify-content: space-between; align-items: baseline;
        gap: 1rem; padding: .7rem 0; border-bottom: 1px solid var(--edge); }
.row-k { color: var(--ink-2); }
.row-v { font-family: var(--mono); font-weight: 600; text-align: right; }
.row.is-unknown .row-v { font-family: var(--face); font-weight: 400;
                         font-style: italic; color: var(--ink-3); }

.lan { margin: 1.2rem 0 0; color: var(--ink-3); font-size: .85rem; }
.lan-link { color: var(--chrome); font-family: var(--mono); }

.plants-h { margin: 1.6rem 0 .5rem; font-size: .72rem; letter-spacing: .08em;
            text-transform: uppercase; color: var(--ink-3); font-weight: 600; }
/* P0f. THE HAIRLINES USED TO BE THE CONTAINER SHOWING THROUGH 1px GAPS, and
   that is what banded. `background: var(--edge)` with `gap: 1px` paints a rule
   between cells -- and paints the SAME COLOUR across every part of the grid no
   cell covers. `auto-fill` creates every track that FITS, occupied or not, so
   the tail of the last row was a block of #d8d3cb the width of the tracks
   nothing landed in: 206px at thirteen ports in a 1442.5px container, 1031px
   at nine, 619px for a four-bucket unit.

   BOTH MEASUREMENTS OF THIS WERE RIGHT AND BOTH MEASURED THE WRONG QUANTITY.
   Track 205.219, item 205.22, gaps 0.97-1.08 against a declared 1px -- all
   correct, and none of them the defect, which was never a mis-sized gap but a
   whole missing cell. That sentence is the reason this took two attempts and
   it is kept here rather than in the queue entry alone.

   TWO CHANGES, AND NEITHER ALONE IS ENOUGH:

     auto-fit  collapses tracks no item occupies, which removes every
               SINGLE-ROW case outright -- four ports on a wide screen now
               fill the row instead of leaving three dead tracks. It does NOT
               fix a multi-row last row, because those columns are occupied
               further up.

     the rule moves ONTO THE CELL. With the container painted --field, the
               last row's remainder is card colour instead of edge colour, so
               there is nothing left to see wherever a cell is absent. This is
               the half that fixes the case auto-fit cannot.

   box-shadow, NOT border, and not gap. A shadow paints outside the box without
   taking part in layout, so the rules cost no width and the track arithmetic
   stays exactly `inner / n`. `overflow: hidden` clips the outermost ones
   against the container's own border, so the frame is one line and not two. */
.plants { display: grid; gap: 0; background: var(--field);
          border: 1px solid var(--edge); border-radius: 8px; overflow: hidden;
          grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); }
/* TWO LINES, NAME FIRST. Real plant names are long -- "Madagascar Dragon Tree",
   "Raindrop Peperomia" -- and a single row sharing its width with a dose and an
   age truncated every one of them to "Mad...". The name is the identity; it gets
   the full width and the figures go underneath. */
.plant  { display: grid; grid-template-columns: auto 1fr; gap: 0 .5rem;
          background: var(--field); padding: .5rem .7rem; font-size: .88rem;
          align-items: baseline;
          /* THE RULE, CARRIED BY THE CELL (P0f). Right and bottom only: each
             cell draws its own two, its neighbours draw the others, and the
             container's border closes the frame. A cell that does not exist
             draws nothing, which is the whole point -- the remainder of a
             short last row is now card colour. */
          box-shadow: 1px 0 0 var(--edge), 0 1px 0 var(--edge); }
.plant.is-empty { color: var(--ink-3); }
.plant-dot { width: .45rem; height: .45rem; border-radius: 50%;
             background: var(--good); flex: none; }
.plant-dot.is-empty { background: none; border: 1px solid var(--idle); }
.plant-name { min-width: 0; overflow: hidden; text-overflow: ellipsis;
              white-space: nowrap; }
.plant-figs { grid-column: 2; display: flex; gap: .5rem;
              font-family: var(--mono); font-size: .72rem; color: var(--ink-3); }

/* ── POINTING AT A PORT ──────────────────────────────────────────────────────
   Hover on a pointer, tap on touch. Anchored to the dot's own coordinates in
   the dial's 0..100 viewBox, so it follows the port wherever the ring geometry
   puts it, and translated up and left so the dot itself stays visible. */
.port-pop.is-below { transform: translate(-50%, 12px); }
.port-pop {
  position: absolute; z-index: 5; pointer-events: none;
  /* OUTWARD FROM THE RING, not always upward. Anchored above a port in the top
     half and below one in the bottom half, so the popover never sits over the
     dial the reader is pointing at -- the first version covered the centre
     number and the port itself. */
  transform: translate(-50%, calc(-100% - 12px));
  background: var(--ground); border: 1px solid var(--edge);
  border-radius: 8px; padding: .5rem .7rem; min-width: 11rem;
  max-width: 20rem;
  white-space: nowrap;
  box-shadow: 0 6px 18px rgba(0,0,0,.45); text-align: left;
}
/* A NAME THIS CONSOLE DID NOT WRITE CAN BE ANY LENGTH.
   Firmware enforces NO name limit -- they grepped for one at my asking and
   there is none in the set_plant path, none in /api/plants, and none in
   storage. The only 48 in the system is this console's validator and the
   unit's own maxlength, and maxlength constrains TYPING, not rendering. So a
   name written by a direct MQTT publish, a config restore, or a future client
   arrives here unbounded, and the device will serve it back forever.
   48 IS MINE AND THE PAGE'S, NOT A PROPERTY OF THE SYSTEM -- so the renderer
   assumes nothing. The strip already ellipsises; this wraps and caps, because
   a popover that grows to fit a 200-character name covers the dial it is
   explaining. */
.port-pop-name { display: block; font-size: .9rem; color: var(--ink);
                 margin-bottom: .3rem; max-width: 18rem;
                 overflow-wrap: anywhere; }
.port-pop-row  { display: flex; justify-content: space-between; gap: .8rem;
                 font-size: .78rem; color: var(--ink-2); }
.port-pop-k    { color: var(--ink-3); }
.port-pop-v    { font-family: var(--mono); }

.empty h2 { margin-bottom: 1rem; }
body.customer .btn { display: inline-block; background: var(--chrome);
                     color: #14120F; font-weight: 600; padding: .6rem 1.2rem;
                     border-radius: 8px; text-decoration: none; }

/* ---------------------------------------------------------------------------
   A6: the tools that are not why the page is opened.
   Soak & builds and Alert SMS were full-height sections BETWEEN the operator
   and the fleet. They are real -- the OTA target list is load-bearing -- so
   they fold rather than go. Closed by default, one click away, and the summary
   carries the name the section heading used to.
   --------------------------------------------------------------------------- */
details.tool-fold {
  margin: 18px 0;
  border: 1px solid var(--line, #d8dee4);
  border-radius: 10px;
  background: #fff;
}
details.tool-fold > summary {
  cursor: pointer;
  padding: 12px 16px;
  font-weight: 600;
  letter-spacing: .02em;
  list-style: none;
}
details.tool-fold > summary::-webkit-details-marker { display: none; }
details.tool-fold > summary::before {
  content: "\25B8";            /* a right-pointing triangle, rotated when open */
  display: inline-block;
  width: 1.1em;
  color: var(--dim, #6b7280);
  transition: transform .12s ease;
}
details.tool-fold[open] > summary::before { transform: rotate(90deg); }
/* The folded sections keep their own padding; drop the duplicated top margin so
   an open fold does not start with a gap the closed one did not have. */
details.tool-fold > section { margin-top: 0; }

/* ---------------------------------------------------------------------------
   RECOVERY. Folded shut, and it does not look like a feature.
   The Direct-Mode password is local control of the unit, needed about once --
   when the network has changed and the machine is unreachable. So it sits at
   the bottom of the device view, closed, and costs a deliberate click. It is
   styled quietly on purpose: a button that looks inviting is one people press
   to see what it does, and every press is an audited disclosure.
   --------------------------------------------------------------------------- */
body.customer details.recovery {
  margin: 28px 0 8px;
  border-top: 1px solid var(--line);
  padding-top: 14px;
}
body.customer details.recovery > summary {
  cursor: pointer;
  color: var(--dim);
  font-size: 14px;
  letter-spacing: .04em;
  list-style: none;
}
body.customer details.recovery > summary::-webkit-details-marker { display: none; }
body.customer details.recovery > summary::before { content: "\203A  "; }
body.customer details.recovery[open] > summary::before { content: "\2304  "; }
body.customer .recovery-btn {
  font: inherit;
  font-size: 14px;
  padding: 8px 14px;
  border: 1px solid var(--line);
  border-radius: 6px;
  background: transparent;
  color: var(--ink);
  cursor: pointer;
}
body.customer .recovery-btn:disabled { opacity: .6; cursor: default; }
/* The password is read off a screen and typed into a phone's wifi dialog, so it
   is monospaced and large -- the same reason the alphabet drops 0/O/1/I/L. */
body.customer .recovery-pass,
body.customer .recovery-ssid {
  font: 600 22px/1.4 ui-monospace, "JetBrains Mono", Consolas, monospace;
  letter-spacing: .08em;
  margin: 2px 0 14px;
  user-select: all;
}
body.customer .recovery-warn {
  border-left: 3px solid var(--warn);
  padding: 8px 12px;
  margin-top: 14px;
  font-size: 14px;
  color: var(--ink);
  background: rgba(178, 106, 0, .08);
}


/* ── THE PLANT PAGE ──────────────────────────────────────────────────────────
 * Two icon rows, one Save, and it has to fit a phone without scrolling -- the
 * target is three taps and no scrolling, so the rows wrap rather than scroll
 * sideways and nothing below Save is required to complete the task. */
/* .plant-page, NOT .plant — TWO COMPONENTS HAD ONE CLASS NAME.
   The plants strip on the device screen also builds `.plant` elements, and
   this rule came LATER with the same specificity, so it won for both. Its
   `margin: 0 auto` is the whole of P0f: an auto margin on a grid item absorbs
   the free space instead of stretching, so every tile sat at its content width
   (~185px) inside a ~250px track and the remainder read as dark gutters —
   against a declared `gap: 1px`.
   Nothing was wrong with the grid, the gap or auto-fill. A name collision was
   restyling a component that had never heard of this rule. */
.plant-page { max-width: 560px; margin: 0 auto; padding: 0 16px; }
.plant-head { display: flex; align-items: baseline; gap: 12px; }
.plant-page .qlabel { margin: 14px 0 6px; font-size: 13px; color: var(--ink-soft); }
/* ONE ROW PER QUESTION, ALWAYS. This was flex-wrap with a 72px min-width, so
   the six pots overflowed onto a second line and left the "Pot" label sitting
   beside a half-empty row. A wrapped row of six also stops reading as a
   SCALE -- the eye compares within a line, and 12 in and 24 in had dropped
   underneath the others.
   grid-auto-flow: column puts every option in one row whatever the count --
   five plants, three sizes, six pots -- and minmax(0,1fr) lets them shrink
   instead of wrapping. At 390px that is ~55px each, still above the 44px
   target. */
.plant-page .opts {
  display: grid; grid-auto-flow: column;
  grid-auto-columns: minmax(0, 1fr); gap: 6px;
}
/* ── TWELVE ICONS DO NOT FIT ONE ROW, AND NEVER DID ───────────────────────
   `.opts` is grid-auto-flow: column, sized for five plants, three sizes and
   six pots -- the comment above it reasons about exactly those. The icon
   picker has TWELVE, so on a 390px phone each cell was 24px wide: under the
   44px touch target this file sets elsewhere, and far under the 44px the
   glyphs need. The block comment at the top of this section has said "the rows
   wrap rather than scroll sideways" the whole time; grid-auto-flow: column
   does not wrap. The prose was right and nothing enforced it -- Z15's rule 10,
   one row of CSS away from the defect it names.

   auto-fit over a 60px floor: nine across on the widest page, four on a
   narrow phone, and it reflows rather than crushing. */
.plant-page .opts.is-picker {
  grid-auto-flow: row;
  grid-template-columns: repeat(auto-fit, minmax(60px, 1fr));
}

.plant-page .opt {
  display: flex; flex-direction: column; align-items: center; gap: 4px;
  min-width: 0; min-height: 44px; padding: 9px 4px;
  border: 1px solid var(--rule);
  border-radius: 6px; background: transparent; color: var(--ink);
  font-size: 11px; cursor: pointer; overflow: hidden;
}
/* 1.6, matching the firmware's own UI exactly. The glyph paths are shared
   verbatim; drawing them at a different weight would reintroduce by CSS the
   divergence sharing the paths was meant to remove. */
/* 44px, NOT 26px, AND THAT IS THE SET'S OWN NUMBER (Z15). icon-set.json's
   min_render_px says picker: 44 -- the size every glyph in it was drawn and
   judged at, with variants rejected for mushing below that. This row shipped
   at 26 and nothing could tell, because the requirement was a sentence.
   The glyph fills its 24-unit viewBox, so the CSS size IS the rendered size
   and the test reads this declaration. */
.plant-page .opt svg { width: 44px; height: 44px; fill: none;
                  stroke: currentColor; stroke-width: 1.6;
                  stroke-linecap: round; stroke-linejoin: round; }
.plant-page .opt.on { border-color: var(--water); color: var(--water); }
/* GREYED, NOT HIDDEN. Removing it would change the row's length as you tap and
   would hide that we know the 2 in pot exists. It keeps its title, so the
   reason is one hover or long-press away. */
.plant-page .opt.off { opacity: .32; cursor: not-allowed; }
.plant-page .opt.off:hover { opacity: .5; }
.plant-page .p-out { margin: 16px 0 4px; font-size: 15px; color: var(--ink-soft); }
.plant-page .p-out.is-ok { color: var(--ink); font-weight: 500; }
.plant-actions { display: flex; align-items: flex-start; gap: 16px;
                 margin-top: 12px; flex-wrap: wrap; }
/* ── WHAT TO DO ABOUT A LATCHED LEAK (Z17) ────────────────────────────────
   Ordered, because the order is the work: find it, fix it, then clear the
   fault. The button sits at the bottom so it is read last. */
.leak-act  { margin: 14px 0 0; text-align: left; max-width: 34ch;
             border-left: 3px solid var(--crit); padding-left: 12px; }
.leak-act h3 { margin: 0 0 6px; font-size: .78rem; letter-spacing: .08em;
               text-transform: uppercase; color: var(--ink-3); }
.leak-steps { margin: 0 0 10px; padding-left: 1.1rem; font-size: .92rem;
              color: var(--ink-2); }
.leak-steps li { margin-bottom: 4px; }
.leak-act .muted { font-size: .85rem; margin: 8px 0 0; }

/* REMOVING A PLUVIO (Z25). Quiet on purpose: it is a real thing an owner
   does when they sell the unit, not a destructive action to be warned away
   from, and a red button here would make an ordinary step feel like a
   mistake. It earns its confirm by being surprising when misclicked, not by
   being dangerous. */
.device-remove { margin-top: 20px; padding-top: 14px;
                 border-top: 1px solid var(--edge); }
.device-remove .linkish { color: var(--ink-3); text-decoration: underline; }
.device-remove .linkish:hover { color: var(--crit); }
.device-remove .muted { margin-top: 8px; font-size: .88rem; max-width: 40ch; }

/* RENAMING, FROM THE NAME ITSELF. Quiet until used: a link under the
   heading, which becomes a field in place. The claim page promised this, so
   it is deliberately not hidden behind a settings screen. */
.device-rename { margin-top: 4px; }
.device-rename .linkish { color: var(--ink-3); text-decoration: underline;
                          font-size: .88rem; }
.rename-form { display: flex; gap: 6px; align-items: center;
               flex-wrap: wrap; margin: 4px 0; }
.rename-input { flex: 1 1 14ch; min-width: 12ch; padding: 4px 6px;
                border: 1px solid var(--edge); border-radius: 4px;
                background: var(--bg-2, transparent); color: inherit;
                font: inherit; }
.rename-save { padding: 4px 10px; border: 1px solid var(--edge);
               border-radius: 4px; background: transparent; color: inherit;
               font: inherit; cursor: pointer; }
.device-rename .muted { margin-top: 6px; font-size: .88rem; max-width: 46ch; }

/* AN UPDATE THE OWNER MAY TAKE. Sits above .device-remove and reads as an
   offer rather than as a status the garden always has: it renders only when
   the server names a build the canary gate has cleared for THIS unit, so on
   most days this rule styles nothing.

   NOT A WARNING COLOUR. An update is an ordinary thing to accept, and the
   card's own text carries the only reassurance that matters -- the settings
   area is somewhere the update cannot reach. */
.device-update { margin-top: 20px; padding: 12px 14px;
                 border: 1px solid var(--edge); border-radius: 6px; }
.device-update h3 { margin: 0 0 6px; font-size: .95rem; }
.device-update p { margin: 6px 0; font-size: .9rem; max-width: 46ch; }
.device-update .linkish { color: var(--ink-3); text-decoration: underline; }

/* THE EMPTY ACCOUNT (2026-09-23). Centred, generous, and not an error: this
   is the state every customer is in five minutes before their first claim. */
.gardens.is-empty { display: grid; place-items: center; padding: 48px 20px; }
.g-empty { max-width: 40ch; text-align: center; }
.g-empty svg { display: block; margin: 0 auto 18px; }
.g-empty h2 { font-family: var(--face-display); font-size: 22px; margin: 0 0 8px; }
.g-empty p { color: var(--ink-2); margin: 0 0 18px; line-height: 1.55; }
.gardens-broken svg { display: block; margin: 0 auto 14px; }
/* The never-reported unit: same treatment as the empty account, because
   it is the same kind of moment -- nothing to show, and nothing wrong. */
.empty { text-align: center; max-width: 42ch; margin: 40px auto; }
.empty svg { display: block; margin: 0 auto 16px; }

/* The platform-health note: a sentence, not a tile, because the thing it
   reports is a COMBINATION of two tiles that each look fine. */
#ops { margin-top: 10px; }
.ops-note { flex-basis: 100%; margin: 6px 0 0; font-size: .9rem;
            color: var(--crit, #a33a2a); max-width: 70ch; }

/* Scheduled mechanisms (G0). A TABLE, not tiles: the question is "which of
   these has not run", which is a list with a state per row -- a tile per
   mechanism would be five numbers that are each always 1. */
.mech { margin: 0 0 20px; }
.mech-h { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap;
          margin-bottom: 6px; }
.mech-title { font-size: 10px; letter-spacing: .07em; text-transform: uppercase;
              color: var(--ink-soft); }
.mech-c { font-size: 11px; color: var(--ink-soft);
          font-variant-numeric: tabular-nums; }
.mech-c.is-bad  { color: var(--bad); font-weight: 700; }
.mech-c.is-warn { color: var(--warn); font-weight: 700; }
.mech-note { font-size: 11px; color: var(--bad); margin: 0 0 8px;
             max-width: 78ch; }
.mech-t { width: 100%; border-collapse: collapse; font-size: 12px;
          background: var(--panel); border: 1px solid var(--rule);
          border-radius: 6px; overflow: hidden; }
.mech-t th { text-align: left; font-weight: 600; color: var(--ink-soft);
             border-bottom: 1px solid var(--rule); padding: .35rem .5rem;
             font-size: 10px; letter-spacing: .06em; text-transform: uppercase; }
.mech-t td { padding: .35rem .5rem; border-bottom: 1px solid var(--rule); }
.mech-t tr:last-child td { border-bottom: none; }
/* The STATE is the leftmost column and the only coloured thing in the row --
   colouring the whole row would make four healthy mechanisms shout too. */
.mech-s { text-transform: uppercase; font-size: 10px; letter-spacing: .06em;
          font-weight: 700; }
.mech-t tr.is-good .mech-s { color: var(--good); }
.mech-t tr.is-warn .mech-s { color: var(--warn); }
.mech-t tr.is-bad  .mech-s { color: var(--bad); }

/* RE-MINT AUTHORISATION (Z42). Operator surface on an admin page: the live
   rows are what somebody came here to see, so they carry the only colour. */
.rm-panel { margin-top: 28px; }
.rm-form { max-width: 46ch; }
.rm-field { display: block; margin-bottom: 14px; }
.rm-label { display: block; font-size: .78rem; letter-spacing: .06em;
            text-transform: uppercase; color: var(--ink-3); margin-bottom: 4px; }
.rm-field input { font: inherit; width: 100%; min-height: 44px;
                  padding: 8px 10px; border: 1px solid var(--edge);
                  border-radius: 6px; background: var(--field); }
.rm-help { display: block; font-size: .85rem; color: var(--ink-3);
           margin-top: 4px; }
.rm-consent { display: flex; gap: 10px; align-items: flex-start;
              margin: 0 0 14px; }
.rm-consent input { width: 20px; height: 20px; margin-top: 2px; flex: none; }
.rm-consent-text { font-size: .92rem; color: var(--ink-2); line-height: 1.45; }
.rm-msg { margin-top: 12px; font-size: .92rem; color: var(--ink-2);
          white-space: pre-line; }
.rm-msg.is-bad { color: var(--crit); }
.rm-msg.is-good { color: var(--good); }
.rm-h { font-size: .78rem; letter-spacing: .06em; text-transform: uppercase;
        color: var(--ink-3); margin: 26px 0 8px; }
.rm-row { border-top: 1px solid var(--edge); padding: 10px 0; }
.rm-row.is-live { border-left: 3px solid var(--chrome); padding-left: 10px; }
.rm-head { display: flex; gap: 12px; align-items: baseline; }
.rm-dev { font-family: ui-monospace, Consolas, monospace; }
.rm-state { font-size: .72rem; letter-spacing: .08em; color: var(--ink-3); }
.rm-row.is-live .rm-state { color: var(--chrome); font-weight: 700; }
.rm-reason { margin: 4px 0 2px; font-size: .95rem; color: var(--ink-2); }
.rm-when { margin: 0; font-size: .82rem; color: var(--ink-3); }

/* ── THE CUSTOMER'S TEXT-MESSAGE SETTINGS (Z8) ─
   ITS OWN PREFIX, NOT `.sms-*`. admin.html's panel already owns that
   namespace, and `.sms-consent` there sets `gap: .5rem` -- two components
   with one class name, which is exactly the P0f defect and exactly what
   the no-contradiction check found on this file's first run.
   (was: THE CUSTOMER'S SMS SETTINGS (Z8) ─────────────────────────────────────
   Quiet, and the consent row is the one thing that is NOT quiet: it is the
   sentence the record reproduces, so it gets the room to be read rather than
   being set small to fit beside a checkbox. */
.txt-form, .txt-settings { max-width: 42ch; margin: 18px 0; }
.txt-head { font-family: var(--face-display); font-size: 19px; margin: 0 0 6px; }
.txt-body { color: var(--ink-2); font-size: .95rem; margin: 0 0 14px; }
.txt-field { display: block; margin-bottom: 14px; }
.txt-label { display: block; font-size: .78rem; letter-spacing: .06em;
             text-transform: uppercase; color: var(--ink-3); margin-bottom: 4px; }
.txt-input { font: inherit; width: 100%; min-height: 44px; padding: 8px 10px;
             border: 1px solid var(--rule); border-radius: 6px;
             background: var(--panel); color: var(--ink); }
.txt-help { display: block; font-size: .85rem; color: var(--ink-3);
            margin-top: 4px; font-style: italic; }
/* THE CONSENT ROW. A 44px target on the box itself, and the sentence at full
   reading size -- shrinking it to fit a layout would be shrinking the thing
   the record quotes. */
.txt-consent { display: flex; gap: 10px; align-items: flex-start;
               margin-bottom: 14px; cursor: pointer; }
.txt-consent input { width: 20px; height: 20px; margin-top: 2px; flex: none; }
.txt-consent-text { font-size: .95rem; color: var(--ink-2); line-height: 1.45; }
.txt-consent-text a { color: var(--chrome); }
.txt-msg { margin-top: 10px; font-size: .92rem; color: var(--ink-2); }
.txt-line { margin: 0 0 4px; }
/* EACH STATE IS A FACT, NOT A SEVERITY. Opted out is not an error somebody
   made, so it is not red; not-yet-confirmed is not a failure, so it is not
   amber-as-alarm. Both take state TEXT values (Z10, Z14). */
.txt-state { font-size: .92rem; color: var(--ink-2); margin: 0 0 10px; }
.txt-state.is-off { color: var(--st-unknown-text); }
.txt-state.is-pending { color: var(--st-watch-text); }
.txt-actions { display: flex; gap: 16px; }

.port-setup {
  display: block; width: 100%; margin-top: 8px; padding: 5px 8px;
  border: 1px solid var(--rule); border-radius: 4px; background: transparent;
  color: var(--ink); font-size: 11px; cursor: pointer;
}


/* ═══════════════════════════════════════════════════════════════════════════
   THE GARDENS PAGE — and there is ONE palette, which is the brand's.

   WHAT THIS REPLACES. The canvas said ops INHERITS the garden: same dial, same
   palette, same words. What I shipped put the warm paper in a `--g-*`
   namespace scoped to `.gardens`, which did not make it the new palette -- it
   made it a THIRD one. A customer crossed a theme boundary going from their
   own grid into their own device page, which nobody ever decided.

   SO THE `--g-*` NAMESPACE IS GONE. pluviogrow.com is the arbiter and it was
   read rather than summarised: its own custom properties are `--bg #f4f1ec`,
   `--ink #1a1a1a`, `--ink-soft #6b6864`, `--rule #d8d3cb`, `--orange #c46f02`,
   `--orange-deep #af6306`, `--water #4a90c2` -- the same tokens this
   stylesheet's `:root` has carried all along. There was never a second palette
   to port. The gardens page uses the brand tokens directly.

   TWO FACES IN THE WHOLE SITE: Helvetica/Arial for display, JetBrains Mono for
   everything else. Light only. Fraunces and Karla are out.

   EVERY STATE IS A MARK AND A TEXT VALUE (Z10), and they are different
   colours on purpose. A dot only has to be TELLABLE APART; a word has to be
   READABLE, and the same hue cannot do both on paper. `watch` is the proof:
   #C08A2E reads fine as a 7px dot and sits at contrast 2.70 against #f4f1ec,
   which fails the 3.0 floor for UI and the 4.5 floor for text -- and it was
   the state pill's TEXT colour.

   Measured, not assumed:

     watch text #8A682E   4.55 on paper   (was 2.70)
     vs --orange          deltaE 33.7
     vs --orange-deep     deltaE 26.6     <- the tightest, still clear
     vs --orange-bright   deltaE 41.8

   chrome_not_state_test proves the state tokens are not chrome tokens. It
   cannot prove two oranges are distinguishable, so that was measured.
   ═══════════════════════════════════════════════════════════════════════════ */
:root {
  /* THE STATES, EACH AS A PAIR. The wash is the pill's ground, the mark is
     its dot, the text is its word. Three values because they do three jobs. */
  --st-watering-mark: #4c7a3f;  --st-watering-text: #3d6633;
  --st-watering-wash: #eef2ea;
  --st-watch-mark:    #c08a2e;  --st-watch-text:    #8a682e;
  --st-watch-wash:    #faf0dc;
  --st-stopped-mark:  #a33a2a;  --st-stopped-text:  #8f3324;
  --st-stopped-wash:  #f8eae5;
  /* NOT KNOWING IS GREY AND IS NEVER AN ALARM. --dead already means
     "present, but never deliverable"; this is the same idea about a fact.

     Z14: BOTH HALVES MOVED, AND THE GREY IS NOW COOL. They were #b9b2a7 and
     #6b6864 -- warm greys, deltaE 17.8 and 11.7 from --chrome-3 #8a8378. The
     text value was the closest state/chrome pair in the whole palette, closer
     than the one Greg objected to in Z10, and it inks "we cannot see this
     unit": a VERDICT that read as furniture, which is a unit quietly dropping
     out of somebody's attention.

     The warm family is where the brand lives, so a grey that has to be
     UNMISTAKABLY NOT THE BRAND cannot be a warm grey. Measured, against every
     chrome token and every other state value:

       mark #7F8FA6   deltaE 21.3 to chrome-3 (81 / 75 / 21)
                      contrast 2.92 paper, 3.13 card, 2.75 well -- above the
                      lowest SHIPPED mark, which is watch at 2.70
       text #55606E   deltaE 22.0 to chrome-3 (78 / 71 / 22)
                      contrast 5.68 paper, 6.08 card, 5.34 well
       mark vs text   deltaE 19.2, between watch's 23.6 and watering's 8.6

     The wash stays warm. It is the pill's GROUND, not a verdict, and #55606E
     on #F1EDE4 clears 4.5. */
  --st-unknown-mark:  #7f8fa6;  --st-unknown-text:  #55606e;
  --st-unknown-wash:  #f1ede4;

  --glyph: #4a7028;             /* plant glyphs, from the site's own green */

  /* TWO FACES, AND MONO IS THE BODY FACE. That is unusual and it is what the
     site does -- Helvetica/Arial is reserved for display. */
  --face-display: Helvetica, Arial, sans-serif;
  --face-body: "JetBrains Mono", ui-monospace, Consolas, monospace;
}

/* THE WARM GROUND IS THE PAGE, NOT A CARD ON BLACK. body.customer sets a
   near-black ground with 24px of side padding, so a warm panel inside it read
   as a light rectangle floating on a dark frame -- two backgrounds arguing
   about which one is the page.
   Bled to the viewport edges with a negative margin that cancels the body's
   padding, and given the full height so there is no dark strip under a short
   list. The topbar is deliberately left alone: it is chrome and belongs to the
   app, not to this page. */
.gardens { background: var(--bg); color: var(--ink);
           font-family: var(--face-body);
           margin: 0 -24px -64px; padding: 22px 28px 64px;
           min-height: calc(100vh - 56px); }
@media (max-width: 560px) {
  .gardens { margin: 0 -24px -64px; padding: 16px 16px 48px; }
}
.gardens h1, .gardens .g-name, .gardens .g-figure, .gardens .g-hub-n {
  font-family: var(--face-display); font-weight: 600; letter-spacing: -0.01em; }

/* The header counts, and it never shouts. */
.g-head { display: flex; align-items: baseline; gap: 10px;
          margin: 0 0 18px; }
.g-head h1 { font-size: 22px; }
.g-head .g-sub { color: var(--ink-soft); font-size: 14px; }

/* ── THE THREE ARRANGEMENTS ─────────────────────────────────────────────────
 *
 * CARDS IS THE DEFAULT AND THE DIAL IS THE LARGEST THING ON IT. What shipped
 * first was the phone row tiled seven across: a narrow column with a small
 * dial on the left. That is the LIST layout at desktop width, and it buries
 * the one element the page exists for.
 *
 * Cards: three per row, dial centred and dominant, name beneath it, then the
 * state. List: one row per garden, small dial left, number hard right. Wall:
 * small tiles, many at a glance, for somebody with thirty units.
 */
.g-grid { display: grid; gap: 16px; }

/* ── CARDS ─────────────────────────────────────────────────────────────── */
.gardens.is-cards .g-grid {
  grid-template-columns: repeat(3, minmax(0, 1fr));
}
@media (max-width: 1080px) {
  .gardens.is-cards .g-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
.gardens.is-cards .g-card {
  grid-template-columns: 1fr;          /* one column: dial, then words */
  justify-items: center; text-align: center;
  padding: 22px 18px 20px; gap: 10px;
}
.gardens.is-cards .g-name   { font-size: 19px; margin: 2px 0 0; }
.gardens.is-cards .g-detail { max-width: 30ch; }

/* ── LIST (and every phone) ────────────────────────────────────────────── */
.gardens.is-list .g-grid { grid-template-columns: 1fr; gap: 10px; }
.gardens.is-list .g-card {
  grid-template-columns: 56px 1fr auto; min-height: 76px;
  padding: 12px 16px; gap: 14px; align-items: center; text-align: left;
}
.gardens.is-list .g-figure { font-size: 26px; text-align: right; }

/* ── CIRCLE ────────────────────────────────────────────────────────────────
   The picture the operator page has always drawn under about eight units, and
   which the garden never had. Placed rather than flowed, so it needs a stage
   with a height -- a grid cannot make a ring. */
.gardens.is-circle .g-grid {
  position: relative; display: block;
  min-height: min(72vw, 560px); margin: 8px auto 0;
  max-width: 720px;
}
.gardens.is-circle .g-card {
  position: absolute; transform: translate(-50%, -50%);
  /* 132px, AND IT IS A MEASUREMENT THAT MATTERS. gardens.js computes how many
     cards fit the ring from this width plus a gutter; changing it here without
     changing CIRCLE_CARD_W there makes the fit calculation a fiction. */
  width: 132px; grid-template-columns: 1fr;
  justify-items: center; text-align: center; padding: 12px 10px; gap: 6px;
}
.gardens.is-circle .g-detail { display: none; }
/* THE HUB LABEL DOES NOT WRAP. "9 gardens" broke across two lines into "9"
   and "gardens", which reads as a number floating above an unrelated word. */
.g-hub { position: absolute; left: 50%; top: 50%;
         transform: translate(-50%, -50%); text-align: center;
         white-space: nowrap; color: var(--ink-soft); }
.g-hub-n { font-family: var(--face-display); font-size: 40px; line-height: 1;
           color: var(--ink); }
.g-hub-k { font-size: 12px; letter-spacing: .06em; }
/* .g-note is gone too: its only writer was the refused-Circle explanation,
   and a refusal needs somebody to have asked. */

/* ── WALL ──────────────────────────────────────────────────────────────── */
.gardens.is-wall .g-grid {
  grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); gap: 10px;
}
.gardens.is-wall .g-card {
  grid-template-columns: 1fr; justify-items: center; text-align: center;
  padding: 12px 8px; gap: 6px;
}
.gardens.is-wall .g-name   { font-size: 14px; }
.gardens.is-wall .g-detail { display: none; }
.gardens.is-wall .g-state  { font-size: 11.5px; padding: 2px 7px; }

.g-card {
  background: var(--panel); border: 1px solid var(--rule);
  border-radius: 14px; display: grid; align-items: center;
  cursor: pointer; width: 100%; font: inherit; color: inherit;
  padding: 16px; text-align: left;
}
.g-card:hover  { border-color: var(--dead); }
.g-card:focus-visible { outline: 2px solid var(--glyph); outline-offset: 2px; }

.g-mid    { min-width: 0; }
.g-name   { font-size: 17px; margin-bottom: 4px; }
.g-figure { font-size: 30px; line-height: 1; }
/* A DASH IS NOT A SMALL NUMBER. Faint ink, so it reads as "nothing to say"
   rather than as a quantity near zero. */
.g-figure.is-none { color: var(--dead); }

/* THE ARRANGEMENT PICKER'S PAINT WENT WITH THE PICKER (Z17): .g-arrange and
   .g-arr styled five buttons no page builds any more. Dead paint is how a
   deleted control comes back. */

/* ── the state chip: ink on its own wash, never a bare dot ─────────────── */
.g-state {
  display: inline-flex; align-items: center; gap: 6px;
  font-size: 13px; padding: 3px 9px; border-radius: 999px;
}
.g-state::before { content: ''; width: 7px; height: 7px; border-radius: 50%;
                   background: currentColor; flex: none; }
.g-state.is-watering { --mark: var(--st-watering-mark);
                     color: var(--st-watering-text);
                     background: var(--st-watering-wash); }
.g-state.is-watch { --mark: var(--st-watch-mark);
                     color: var(--st-watch-text);
                     background: var(--st-watch-wash); }
.g-state.is-stopped { --mark: var(--st-stopped-mark);
                     color: var(--st-stopped-text);
                     background: var(--st-stopped-wash); }
.g-state.is-unknown { --mark: var(--st-unknown-mark);
                     color: var(--st-unknown-text);
                     background: var(--st-unknown-wash); }

.g-detail { color: var(--ink-soft); font-size: 12.5px; }
.g-lastseen { color: var(--dead); font-size: 12px; }

/* ── the dial IS the machine ───────────────────────────────────────────── */
/* WHERE A DIAL SHOULD HAVE BEEN (Z20). Sized so the card does not collapse
   and the ones beside it keep their grid; empty, because there is nothing
   true to draw and a broken-image glyph would be the page apologising in a
   language nobody reads. */
.g-dial-broken { width: 100%; aspect-ratio: 1; max-width: 176px;
                 border: 1px dashed var(--rule); border-radius: 50%;
                 opacity: .5; }
/* AND WHERE THE WHOLE SCREEN SHOULD HAVE BEEN. A list that still works. */
.gardens-broken { padding: 24px 0; display: flex; flex-direction: column;
                  align-items: flex-start; gap: 8px; color: var(--ink-2); }
.gardens-broken p { margin: 0 0 6px; }
.g-dial { display: block; }
.g-dial .slot-empty { fill: var(--panel); stroke: var(--rule);
                      stroke-width: 1.4; }
/* COLOR, NOT FILL. The glyph's path strokes with `currentColor`, which
   resolves to the CSS **color** property -- setting `fill` here left it
   inheriting the page ink, and every plant rendered as a black blob: a ring
   of dark holes instead of a garden. */
.g-dial .slot-plant { color: var(--glyph); }
.g-dial .deadslot   { fill: none; stroke: var(--dead);
                      stroke-width: 1.2; stroke-dasharray: 2 2; }
.g-dial .rim        { fill: none; stroke: var(--rule); stroke-width: 1.2; }
/* THE FOUR CHANNELS OF STALENESS, and colour is only one of them. Dashed rim,
   hatched tile, the WORD and the AGE -- each survives greyscale, a projector,
   and a reader who cannot separate grey from green. */
.g-dial .rim.is-stale { stroke-dasharray: 4 3; }
.g-card.is-stale { filter: saturate(.55);
  background-image: repeating-linear-gradient(45deg,
    transparent 0 6px, var(--rule) 6px 7px); }
.g-dial .arm            { stroke-width: 3; stroke-linecap: round; }
/* ── IDENTITY COLOUR AND STATUS COLOUR ARE DIFFERENT JOBS ──────────────
 *
 * THE ARM WHILE IT IS DELIVERING DEPICTS WATER, so it takes `--water` --
 * the one home for the colour of water. It was `--st-watering-mark`,
 * which is a STATUS token and is the same green as `--good`, so one
 * substance was drawn in two colours ON ONE DRAWING: the dial's `.water`
 * filled blue (line below) while the arm pouring it stroked green, with
 * the two declarations 1,673 lines apart.
 *
 * THE OTHER THREE ARM STATES STAY ON THEIR STATUS TOKENS, and that is the
 * rule rather than an exception: watch, stopped and unknown are STATES in
 * which no water is moving, so nothing there depicts a substance. The
 * forbidden thing is a status token colouring something that DRAWS WATER.
 *
 * A status BADGE that says "this unit is watering" may stay green --
 * `.g-state.is-watering` does, deliberately. That is a state, not a
 * substance.
 *
 * ASSERTED, NOT JUST CORRECTED: tests/test_water_is_blue.py declares the
 * water-drawing set and refuses an `--st-*` or `--good` token inside it.
 * C63 -- make the misuse require new code rather than trusting the next
 * hex somebody types to be right.
 */
.g-dial .arm.is-watering{ stroke: var(--water); }
.g-dial .arm.is-watch   { stroke: var(--st-watch-mark); }
.g-dial .arm.is-stopped { stroke: var(--st-stopped-mark); }
.g-dial .arm.is-unknown { stroke: var(--st-unknown-mark); }
/* UNTRUSTED IS DIMMED, STALE IS GREY. A position we cannot vouch for must not
   look live, and a unit we have not heard from must look its age. */
.g-dial .arm.untrusted  { opacity: 0.4; stroke-dasharray: 3 3; }
.g-dial .arm.stale      { stroke: var(--st-unknown-text); opacity: 0.55; }
/* THE NUMBER NOW SITS INSIDE THE TANK, so it can have water behind it (Z13).
   A full tank puts --water under the figure and a watch-coloured number on
   that is contrast 1.7 -- unreadable, and only in the one state where reading
   it matters. `paint-order: stroke` lays a thin halo of the TANK'S OWN colour
   under the glyphs, so the number is read against --field-2 whatever the level
   is: 4.27 for the watch value, which clears the 3.0 floor this size is held
   to. The old dial solved the same problem with a black text-shadow, which was
   right on a near-black ground and is wrong on paper. */
.g-dial .centre { font-family: var(--face-display); font-size: 22px;
                  font-weight: 600; fill: var(--ink); text-anchor: middle;
                  paint-order: stroke; stroke: var(--field-2);
                  stroke-width: 2.5; stroke-linejoin: round; }
.g-dial .centre.is-none { fill: var(--dead); }
/* ── THE TANK, AND THE WATER IN IT (Z13) ──────────────────────────────────
   Greg, in one breath with the icons: "also include the water level". The
   device dial had it and the garden card did not -- same machine, same
   question. One dial now, so both have it at every size. */
.g-dial .body  { fill: var(--field-2); stroke: var(--edge); stroke-width: 1; }
/* NO READING, NO GAUGE (Z17). A filled well with no water in it is a picture
   of an empty tank, and an empty tank is a reading. Where there is none --
   unweighable, no denominator, a numerator rejected as impossible -- the body
   is the chassis outline and nothing else, so the dial stops contradicting the
   words printed beside it. A MEASURED zero keeps the filled well; that is what
   makes "empty" and "we do not know" different pictures. */
.g-dial .body.is-nogauge { fill: none; stroke-dasharray: 3 2.5; }
.g-dial .body.is-watering { stroke: var(--busy); stroke-width: 2; }
/* WATER IS BLUE, AND THIS IS THE AMBER-IS-CHROME RULE BEING APPLIED RATHER
   THAN OVERTURNED. Water was amber because water is the product, not a
   verdict -- true, and it held against a near-black ground where amber was the
   only warm thing on the page. On paper it stopped holding: amber water sits
   beside amber headings and amber links and becomes the loudest element on
   every card while meaning nothing. Chrome had been LENT to the one thing that
   is not chrome, and the loan is repaid. --water is a brand token from
   pluviogrow.com, so this is still the site's own palette. */
.g-dial .water { fill: var(--water); opacity: .85; }

/* ── A PORT IS AN ICON, AND ITS STATE IS A SHAPE ──────────────────────────
   Z13 ruling 4. These were .port.is-due / .is-short / .is-dry -- filled discs
   whose only difference was hue, and Greg read the amber ones as chrome. The
   token was never a chrome token (--warn is deltaE 26.6 from --chrome-2, and
   chrome_not_state_test proves the identity); the SHAPE was chrome's. A ring
   around the glyph is a shape nothing else on this dial makes. */
/* Z15 RULING 4: IT IS AN ARC ON ITS OWN RADIUS, NOT A RING ON THE GLYPH.
   As a concentric circle it was 15.6 units around a 12-unit glyph -- 2.66px of
   clearance on a card and 1.01px on the phone row, which is not an annotation,
   it is a second drawing on top of the first. And no bigger ring was available:
   thirteen slots sit 18.19 units apart, so any concentric ring with real
   separation overlaps its neighbours. Measured, in gardens.js, beside the
   constant. Butt caps, because a round cap on a short arc reads as a bead. */
.g-dial .slot-mark       { fill: none; stroke-width: 3;
                           stroke-linecap: butt; }
.g-dial .slot-mark.is-due   { stroke: var(--st-watch-mark); }
.g-dial .slot-mark.is-short { stroke: var(--st-stopped-mark); }

/* ── BELOW THE SET'S RENDER FLOOR: A DOT, AND IT IS NOT A CONSOLATION ──────
   At 56px (the phone row), 76px (circle) and 84px (wall) a glyph lands at
   6.7, 9.1 and 10.1 CSS pixels against the 17px this set was drawn for. It
   is not a smaller plant, it is a smudge. A dot is legible at four pixels
   because it is not a picture of anything, and it still carries presence and
   state -- which is the whole of what those three arrangements were asking
   the slot for. */
.g-dial .slot-dot             { stroke-width: 0; }
.g-dial .slot-dot.is-planted  { fill: var(--glyph); }
.g-dial .slot-dot.is-due      { fill: var(--glyph); }
.g-dial .slot-dot.is-short    { fill: var(--st-stopped-mark); }
.g-dial .slot-dot.is-unknown  { fill: var(--st-unknown-mark); }
/* AND THE GLYPH ITSELF FOR THE TWO THAT MUST SURVIVE GREYSCALE. `short` is a
   red plant inside a red ring; `unknown` is a grey plant with no ring at all,
   because we cannot claim a plant is fine and cannot claim it is thirsty --
   but WHICH plant it is stays true, so the icon stays. */
.g-dial .slot-plant.is-short   { color: var(--st-stopped-mark); }
.g-dial .slot-plant.is-unknown { color: var(--st-unknown-mark); }

/* THE CENTRE FIGURE CARRIES THE VERDICT, which is where the deleted attention
   ring went. Z10: every state is a mark AND a text value; this is text, so it
   takes the text value and is held to the 4.5 floor by
   tests/test_cascade_contrast.py like every other word on the page. */
.g-dial .centre.is-crit { fill: var(--st-stopped-text); }
.g-dial .centre.is-warn { fill: var(--st-watch-text); }

/* ── PHONE: one garden per row, whatever was chosen ────────────────────────
   The view Greg actually uses. Ring left, the number he acts on hard against
   the right edge, every target at least 44px so it can be hit with a thumb
   while holding a watering can. */
@media (max-width: 560px) {
  .gardens .g-grid { grid-template-columns: 1fr !important; gap: 10px; }
  .gardens .g-card {
    grid-template-columns: 56px 1fr auto !important; min-height: 76px;
    padding: 12px 14px; gap: 12px; align-items: center;
    justify-items: stretch !important; text-align: left !important;
  }
  .gardens .g-detail { display: none; }
  .gardens .g-figure { font-size: 26px; text-align: right; }
}

/* ── FLEET OPS inherits all of it and adds FIVE COLUMNS ────────────────────
   Same dial, same colours, same words, so a screenshot from either side reads
   as one product. Ops is not a different application; it is this page with
   more of the truth on it. */
.g-ops .g-card { grid-template-columns: 96px 1fr auto; }
.g-opscols { display: grid; gap: 2px 14px; font-size: 12px;
             grid-template-columns: repeat(5, auto); align-items: baseline; }
.g-opscols dt { color: var(--dead); }
.g-opscols dd { color: var(--ink-soft); }
/* MONO ONLY HERE. A build string and a fingerprint are compared character by
   character, not read as words. */
.g-opscols .tok { font-family: var(--face-body); font-size: 11.5px;
                  color: var(--ink); }
.g-opscols .over { color: var(--st-stopped-text); }
.g-ops-actions { display: flex; gap: 8px; margin-top: 8px; }
.g-ops-actions button { font: inherit; font-size: 12.5px; padding: 6px 10px;
  min-height: 32px; border: 1px solid var(--rule); border-radius: 8px;
  background: var(--panel); color: var(--ink); cursor: pointer; }
.g-ops-actions button:hover { border-color: var(--dead); }

/* NO DARK OVERRIDE FOR THE GARDENS PAGE. The site is LIGHT ONLY, and a
   palette that changes underneath a screenshot is two palettes again. */
