/* ===========================================================================
   Grodd fleet portal — design system.

   The approved Grodd language (grodd-app/src/theme/tokens.ts), translated to
   the web: porcelain grounds, fir ink, forest for anything Grodd owns, water
   blue ONLY for water, amber/rust semantic-only. Fraunces for identity
   moments — titles, pot names, big readings; the system face for everything
   functional.

   Two rules of the house restated as CSS discipline:
   - Amber (--attention) and rust (--fault) are meaning, never decoration.
   - Offline is muted (opacity .45 + faint ink), never alarmed.
   =========================================================================== */

/* Fraunces, self-hosted — no CDN, the portal must work on a LAN with no
   internet and must not phone a font host from a public page. Variable
   weight 400–600. */
@font-face {
  font-family: 'Fraunces';
  src: url('../fonts/fraunces-latin.woff2') format('woff2');
  font-weight: 400 600;
  font-display: swap;
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
    U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215;
}
@font-face {
  font-family: 'Fraunces';
  src: url('../fonts/fraunces-latin-ext.woff2') format('woff2');
  font-weight: 400 600;
  font-display: swap;
  unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB,
    U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

/* ------------------------------------------------------------------ tokens */
:root {
  /* palette — light (porcelain) */
  --ground: #ecefe7;
  --surface: #f9faf6;
  --card: #ffffff;
  --ink: #14251c;
  /* The neutral ramp is measured against --ground (#ecefe7), not against
     --card, because that is the darkest surface these two land on and the
     sidebar and the pot hero both put real sentences straight onto it.
     --ink-faint was #6b7a70, which was 4.5:1 on white and 3.9:1 there — the
     card was the only surface it had ever been checked on. Fixing it alone
     would have collapsed it into --ink-dim, so --ink-dim moved with it and
     the three steps are now 13.8 / 6.4 / 4.7 on --ground. */
  --ink-dim: #4c584f;
  --ink-faint: #5f6c64;
  /* Structural, never a sentence. Chart axes, dividers, inactive glyphs,
     placeholder text, the asleep/never-reported reach glyphs. It exists so
     --ink-faint can go back to meaning exactly one thing — "no longer
     current" — instead of doing decorative duty as well. 3.6:1 on --ground,
     which clears the 3:1 bar for a glyph or a rule and does NOT clear AA
     for text; the small-caps caption below is inked in --ink-faint for that
     reason. Shared by value with grodd-app/src/theme/tokens.ts `stone`. */
  --stone: #6e7f86;
  /* The plinth — the hairline a pot sits on. Three weights; the middle one
     is the shelf's. */
  --plinth-hairline: rgba(20, 37, 28, 0.14);
  --plinth: rgba(20, 37, 28, 0.22);
  --plinth-heavy: rgba(20, 37, 28, 0.38);
  /* The dark ground the widgets and the watch use. Not a theme: the portal's
     dark theme is its own ramp below. Here so the Logs table can opt into
     the same night the companions use. */
  --night: #10201a;
  --line: #d8decf;
  --line-soft: #e4e8dc;
  --forest: #1d4732;
  --forest-deep: #14331f;
  /* Forest at a weight that can carry text. Identical to --forest here and
     lighter in the dark theme, where --forest is a BUTTON colour and reaches
     only 2.5:1 against a card — every link in the portal, and the "what to
     do about it" line on every issue, were being drawn in it. */
  --forest-ink: #1d4732;
  --leaf: #4e8a5f;
  --water: #4e86ac;
  --water-deep: #2f5f82;
  /* 4.7:1 on its own tint over --ground, which is the "settings pending"
     pill in the pot hero and the alert count in the sidebar. It was #a8741c,
     3.1:1 in that pair. The tints below are deliberately left at the older,
     lighter amber and rust: the wash is a surface, and pinning it to the ink
     would darken the chip every time the ink got more readable. */
  --attention: #815916;
  --fault: #8a2b1d;

  /* The fleet ring's own three hues, and they exist because seven segments
     were being drawn in four colours. Low water, low battery and open alert
     all took --attention and not-reporting and needs-calibrating both took
     --ink-faint, so a fleet with three kinds of trouble in it drew one wide
     amber band and one wide grey one — the 3px gap between arcs was the only
     thing saying "these are two facts", and at 14px it is not enough.
     Severity still reads, because the loud family stays adjacent on the hue
     wheel (rust → copper → gold) and nothing quiet borrows from it.
     Contrast is 4.6:1 or better on --card, well past the 3:1 that
     non-text needs; the legend still names every segment, so identity has
     never rested on colour alone and does not start now. */
  --ring-water: #2f6f8f;
  --ring-alert: #a85f24;
  --ring-uncal: #6d5185;

  --on-forest: #f6f8f4;
  /* Ink for the one place --fault is a background (.sev.critical). White
     here at 8.6:1; near-black in the dark theme, where --fault has to be
     light enough to be read as text. */
  --on-fault: #ffffff;
  /* Ink for a label sitting ON the water fill. Porcelain gives 3.7:1 over
     light water and 2.8:1 over dark; near-black clears AA over both. */
  --on-water: #0e1712;

  --tint-pending: rgba(168, 116, 28, 0.12);
  --tint-good: rgba(78, 138, 95, 0.14);
  --tint-fault: rgba(138, 43, 29, 0.1);
  --tint-neutral: rgba(22, 36, 28, 0.06);
  --tint-water: rgba(78, 134, 172, 0.12);
  --tint-forest: rgba(29, 71, 50, 0.08);

  /* chart series (single-series small multiples; identity by panel title) */
  --series-soil: #1d4732;
  --series-water: #4e86ac;
  --series-dose: #4e8a5f;
  --series-neutral: #5c6b60;

  --shadow-card: 0 3px 12px rgba(20, 37, 28, 0.05);
  --shadow-pop: 0 12px 40px rgba(20, 37, 28, 0.18);

  --font-display: 'Fraunces', ui-serif, Georgia, serif;
  --font-sans: system-ui, -apple-system, 'Segoe UI', Roboto, Inter, sans-serif;
  --font-mono: ui-monospace, 'SF Mono', Menlo, 'Roboto Mono', monospace;

  --r-sm: 4px;
  --r-md: 8px;
  --r-lg: 14px;
  --r-xl: 20px;

  --sidebar-w: 232px;
  color-scheme: light;
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme='light']) {
    --ground: #0e1712;
    --surface: #131f18;
    --card: #182720;
    --ink: #e6ece4;
    --ink-dim: #9fb0a4;
    /* Measured against --card (#182720) here — the LIGHTEST surface, which
       is the binding one when the ink is light. #7d8d81 was 4.4:1 there. */
    --ink-faint: #829286;
    --stone: #8b9aa0;
    --plinth-hairline: rgba(230, 236, 228, 0.14);
    --plinth: rgba(230, 236, 228, 0.22);
    --plinth-heavy: rgba(230, 236, 228, 0.38);
    --line: #263830;
    --line-soft: #1f2f27;
    --forest: #2e6b4a;
    --forest-deep: #1d4732;
    --forest-ink: #4db37c;
    --leaf: #5b9a6e;
    --water: #5f9cc4;
    --water-deep: #82b4d4;
    --attention: #d19a3d;
    /* #c24e32 read at 2.9:1 on its own tint and 3.3:1 on a card — the pump
       fault pill, the error severity badge and the sign-in error were all
       below AA in this theme. Lighter rust; the deeper one stays in the
       tint below and as the .sev.critical background. */
    --fault: #d87f69;

    /* Lightened with the rest of this theme rather than reused from the
       light one: 5.5:1 or better on --card (#182720). The copper sits
       between the rust and the gold on purpose — loud, and not either of
       its neighbours. */
    --ring-water: #6aa8cb;
    --ring-alert: #e09a63;
    --ring-uncal: #a98ec4;

    --on-forest: #f6f8f4;
    --on-fault: #0e1712;
    --on-water: #0e1712;

    --tint-pending: rgba(209, 154, 61, 0.14);
    --tint-good: rgba(91, 154, 110, 0.16);
    --tint-fault: rgba(194, 78, 50, 0.14);
    --tint-neutral: rgba(230, 236, 228, 0.07);
    --tint-water: rgba(95, 156, 196, 0.14);
    --tint-forest: rgba(91, 154, 110, 0.1);

    --series-soil: #6fae84;
    --series-water: #5f9cc4;
    --series-dose: #5b9a6e;
    --series-neutral: #9fb0a4;

    --shadow-card: 0 3px 12px rgba(0, 0, 0, 0.25);
    --shadow-pop: 0 12px 40px rgba(0, 0, 0, 0.5);
    color-scheme: dark;
  }
}
:root[data-theme='dark'] {
  --ground: #0e1712;
  --surface: #131f18;
  --card: #182720;
  --ink: #e6ece4;
  --ink-dim: #9fb0a4;
  --ink-faint: #829286;
  --stone: #8b9aa0;
  --plinth-hairline: rgba(230, 236, 228, 0.14);
  --plinth: rgba(230, 236, 228, 0.22);
  --plinth-heavy: rgba(230, 236, 228, 0.38);
  --line: #263830;
  --line-soft: #1f2f27;
  --forest: #2e6b4a;
  --forest-deep: #1d4732;
  --forest-ink: #4db37c;
  --leaf: #5b9a6e;
  --water: #5f9cc4;
  --water-deep: #82b4d4;
  --attention: #d19a3d;
  --fault: #d87f69;
  /* Kept in step with the media-query block above by hand, like every other
     token here — the two dark blocks are duplicates on purpose so the theme
     toggle can beat the OS preference. */
  --ring-water: #6aa8cb;
  --ring-alert: #e09a63;
  --ring-uncal: #a98ec4;
  --on-forest: #f6f8f4;
  --on-fault: #0e1712;
  --on-water: #0e1712;
  --tint-pending: rgba(209, 154, 61, 0.14);
  --tint-good: rgba(91, 154, 110, 0.16);
  --tint-fault: rgba(194, 78, 50, 0.14);
  --tint-neutral: rgba(230, 236, 228, 0.07);
  --tint-water: rgba(95, 156, 196, 0.14);
  --tint-forest: rgba(91, 154, 110, 0.1);
  --series-soil: #6fae84;
  --series-water: #5f9cc4;
  --series-dose: #5b9a6e;
  --series-neutral: #9fb0a4;
  --shadow-card: 0 3px 12px rgba(0, 0, 0, 0.25);
  --shadow-pop: 0 12px 40px rgba(0, 0, 0, 0.5);
  color-scheme: dark;
}

/* -------------------------------------------------------------------- base */
* {
  box-sizing: border-box;
}
html {
  height: 100%;
}
body {
  margin: 0;
  min-height: 100%;
  background: var(--ground);
  color: var(--ink);
  font: 400 15px/1.5 var(--font-sans);
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}
h1, h2, h3, h4, p {
  margin: 0;
}
a {
  color: var(--forest-ink);
  text-decoration: none;
}
a:hover {
  text-decoration: underline;
}
button {
  font: inherit;
  color: inherit;
  background: none;
  border: 0;
  padding: 0;
  cursor: pointer;
}
code, .mono {
  font-family: var(--font-mono);
  font-size: 0.86em;
}
:focus-visible {
  outline: 2px solid var(--water);
  outline-offset: 2px;
  border-radius: var(--r-sm);
}
::selection {
  background: var(--tint-good);
}

.skip {
  position: absolute;
  left: -9999px;
  top: 8px;
  z-index: 100;
  background: var(--card);
  padding: 8px 14px;
  border-radius: var(--r-md);
  box-shadow: var(--shadow-pop);
}
.skip:focus {
  left: 8px;
}

/* Read but not seen. Clipped rather than `display:none`, because a hidden
   node is hidden from a screen reader too — which is the whole point of the
   modifier below. */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
/* Silent where the layout has stacked this graphic on top of the text that
   already says the same thing — the fleet ring and its chips below 720px.
   `display:none` is deliberate: it is the one thing a media query can do
   that takes a node out of the accessibility tree, and aria-hidden is not
   something CSS can set. */
@media (max-width: 720px) {
  .sr-only.wide-only {
    display: none;
  }
}

/* -------------------------------------------------------------- typography */
.t-hero {
  font-family: var(--font-display);
  font-weight: 600;
  font-size: 32px;
  line-height: 1.15;
  letter-spacing: -0.5px;
}
.t-display {
  font-family: var(--font-display);
  font-weight: 600;
  font-size: 26px;
  line-height: 1.2;
  letter-spacing: -0.3px;
}
.t-title {
  font-family: var(--font-display);
  font-weight: 600;
  font-size: 19px;
  line-height: 1.25;
  letter-spacing: -0.1px;
}
.t-stat {
  font-family: var(--font-display);
  font-weight: 600;
  font-size: 27px;
  line-height: 1.1;
  letter-spacing: -0.3px;
  font-variant-numeric: tabular-nums;
}
.t-caption {
  /* Small caps, not uppercase. The caption used to be system 500 at 11px
     with half a pixel of tracking, uppercased — which shouts at exactly the
     size nobody reads. `all-small-caps` holds the same grid, reads as
     engraving rather than as a warning, and sits properly beside Fraunces.
     ALL-small-caps rather than small-caps so the sixty existing callers —
     some written in capitals, some in sentence case — all render alike; a
     browser without the OpenType feature synthesises the glyphs. */
  font-size: 12px;
  font-weight: 600;
  letter-spacing: 0.06em;
  font-variant-caps: all-small-caps;
  text-transform: none;
  color: var(--ink-faint);
}
.t-label {
  font-size: 13px;
  font-weight: 500;
}
.t-dim {
  color: var(--ink-dim);
}
.t-faint {
  color: var(--ink-faint);
}
.reading {
  font-size: 15px;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}
.num {
  font-variant-numeric: tabular-nums;
}

/* ------------------------------------------------------------------- shell */
.shell {
  display: grid;
  grid-template-columns: var(--sidebar-w) 1fr;
  min-height: 100vh;
}
.sidebar {
  position: sticky;
  top: 0;
  height: 100vh;
  display: flex;
  flex-direction: column;
  border-right: 1px solid var(--line-soft);
  padding: 20px 14px 14px;
  gap: 4px;
  overflow-y: auto;
}
.brand {
  display: flex;
  align-items: center;
  gap: 9px;
  padding: 2px 10px 18px;
  font-family: var(--font-display);
  font-weight: 600;
  font-size: 21px;
  letter-spacing: -0.2px;
}
.brand svg,
.brand .mark {
  flex: none;
  border-radius: 7px;
}
.brand .sub {
  color: var(--ink-faint);
  font-size: 15px;
  font-weight: 400;
  margin-top: 3px;
}
.nav-label {
  padding: 14px 10px 5px;
}
.nav a {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 7px 10px;
  border-radius: var(--r-md);
  color: var(--ink-dim);
  font-size: 14px;
  font-weight: 500;
}
.nav a:hover {
  background: var(--tint-neutral);
  color: var(--ink);
  text-decoration: none;
}
.nav a.active {
  background: var(--forest);
  color: var(--on-forest);
}
.nav a.active svg {
  color: var(--on-forest);
}
.nav a svg {
  flex: none;
  color: var(--ink-faint);
}
.nav .count {
  margin-left: auto;
  font-size: 12px;
  color: var(--ink-faint);
  font-variant-numeric: tabular-nums;
}
.nav a.active .count {
  color: var(--on-forest);
  opacity: 0.75;
}
.nav .alert-count {
  margin-left: auto;
  min-width: 19px;
  text-align: center;
  padding: 1px 6px;
  border-radius: 999px;
  background: var(--tint-pending);
  color: var(--attention);
  font-size: 11.5px;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}
.nav .alert-count.critical {
  background: var(--tint-fault);
  color: var(--fault);
}
.nav .alert-count:empty {
  display: none;
}
.sidebar-foot {
  margin-top: auto;
  padding-top: 14px;
  border-top: 1px solid var(--line-soft);
  display: flex;
  flex-direction: column;
  gap: 6px;
}
.searchbtn {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 7px 10px;
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  background: var(--card);
  color: var(--ink-faint);
  font-size: 13px;
}
.searchbtn:hover {
  color: var(--ink-dim);
}
.searchbtn kbd {
  margin-left: auto;
}
kbd {
  font-family: var(--font-sans);
  font-size: 11px;
  border: 1px solid var(--line);
  border-bottom-width: 2px;
  border-radius: 5px;
  padding: 1px 5px;
  color: var(--ink-dim);
  background: var(--surface);
}
.conn {
  display: flex;
  align-items: center;
  gap: 7px;
  padding: 5px 10px;
  font-size: 12.5px;
  color: var(--ink-faint);
}
.conn /* The fleet table's severity stripe.
   State in the row's left margin, so what is wrong reads as a column of
   colour down the edge of the table instead of forty dots each found one
   row at a time. Drawn on the first cell's ::before rather than as a
   border, because a border-left on a <tr> is ignored under
   border-collapse. Purely visual — the dot and the words in the row carry
   the same state to a screen reader. */
tr.stripe > td:first-child {
  position: relative;
  padding-left: 14px;
}
tr.stripe > td:first-child::before {
  content: "";
  position: absolute;
  left: 2px;
  top: 6px;
  bottom: 6px;
  width: 3px;
  border-radius: 2px;
  background: var(--leaf);
}
/* Every tone spelled out, including the two that were missing.
   The bare rule above defaults to leaf, which is fine for `healthCopy()`
   (good|fault|unknown|attention — the two green cases coincide) and a trap
   for any other vocabulary: `watering.mjs`'s verdicts are ok|unknown, whose
   TONE_CLASS is good|neutral, and neither had a rule — so a probe-less
   board, which knows nothing, would have fallen through to the bare
   selector and been painted healthy green. A stripe that says "fine" about
   a pot nobody can judge is rule 2 in a colour. */
tr.stripe.good > td:first-child::before { background: var(--leaf); }
tr.stripe.attention > td:first-child::before { background: var(--attention); }
tr.stripe.fault > td:first-child::before { background: var(--fault); }
tr.stripe.unknown > td:first-child::before,
tr.stripe.neutral > td:first-child::before,
tr.stripe.pending > td:first-child::before { background: var(--line); }

.dot {
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--leaf);
}
.conn.off .dot {
  background: var(--attention);
}
.conn.off {
  color: var(--attention);
}

/* topbar (mobile) */
.topbar {
  display: none;
  position: sticky;
  top: 0;
  z-index: 30;
  align-items: center;
  gap: 12px;
  padding: 10px 14px;
  background: color-mix(in srgb, var(--ground) 88%, transparent);
  backdrop-filter: blur(10px);
  border-bottom: 1px solid var(--line-soft);
}
.topbar .brand {
  padding: 0;
  font-size: 18px;
}
.burger {
  padding: 6px;
  border-radius: var(--r-md);
  color: var(--ink-dim);
}

main {
  padding: 28px 32px 64px;
  max-width: 1280px;
  width: 100%;
  margin: 0 auto;
  min-width: 0;
}

/* Fail-closed gate: with REQUIRE_AUTH and no session, the portal is a login
   screen and nothing else — no navigation, no chrome, no page behind it.

   A class on <body> so any surface added later is hidden-while-locked by
   default, and `:not(#signin)` rather than a list of what to hide, so a new
   panel cannot be forgotten. This is presentation; the control is that the
   server answers every data route with empty, 401 or 404 to a caller with
   no session. Both, because either alone is a single point of failure. */
body.gated main > :not(#signin) {
  display: none !important;
}
body.gated .sidebar,
body.gated .topbar,
body.gated .scrim {
  display: none !important;
}
body.gated .shell {
  grid-template-columns: 1fr;
}
body.gated main {
  display: grid;
  place-items: center;
  min-height: 100vh;
  padding: 24px;
  max-width: none;
}
#signin {
  display: none;
}
body.gated #signin {
  display: block;
  width: 100%;
  max-width: 380px;
}

/* ------------------------------------------------------ the platform notice

   Said on every page, because the System page's own banner is only read by
   somebody already standing on it — and the reason to go there is the thing
   it says.

   Above the page rather than over it. A modal would be right for something
   the reader has to answer and wrong for this: the broker being down does
   not stop them finishing the filter they were typing, and a dialog they
   have to dismiss to keep working is one they learn to dismiss unread. It
   takes a row of vertical space and gives it back when the fault clears.

   Only `down` gets here. Degraded stops at the nav badge — see
   `systemNotice()`. */
.sysbanner {
  display: flex;
  align-items: flex-start;
  gap: 12px;
  padding: 12px 14px;
  margin-bottom: 18px;
  border: 1px solid var(--line);
  border-left: 3px solid var(--fault);
  border-radius: var(--r-md);
  background: var(--tint-fault);
  font-size: 13.5px;
}
.sysbanner.attention {
  border-left-color: var(--attention);
  background: var(--tint-pending);
}
/* `#sysbanner` is deliberately NOT `display: none` while empty, though it
   looks like it should be. It is a `role="status"` live region, and a region
   that is not rendered is not in the accessibility tree — so hiding it during
   the quiet period makes the first fault a reveal and an insertion in one
   mutation, which is exactly the pattern screen readers are documented to
   miss. The announcement the banner exists for would be the one not spoken.

   It costs nothing to leave rendered: empty, it measures 0 height with no
   margin, padding or border, and `#view` sits at an identical offset either
   way. Same shape as `#toasts` and `.signin-err`, which are also always
   present and empty most of the time. */
.sysbanner .sysbanner-body {
  min-width: 0;
  flex: 1;
}
.sysbanner .sysbanner-title {
  font-weight: 600;
  color: var(--ink);
}
.sysbanner .sysbanner-detail {
  color: var(--ink-dim);
  margin-top: 2px;
  /* Service names are compose services and systemd units; some are long and
     none may be broken across a line in a way that changes what they read
     as. */
  overflow-wrap: anywhere;
}
.sysbanner .sysbanner-acts {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-shrink: 0;
}
/* Below the sidebar breakpoint the row becomes a stack: at 375px the title,
   a link and a dismiss button on one line leaves about four characters for
   the title. */
@media (max-width: 620px) {
  .sysbanner {
    flex-wrap: wrap;
  }
  .sysbanner .sysbanner-acts {
    width: 100%;
    justify-content: flex-end;
  }
}

/* ------------------------------------------------------------------- cards */
.card {
  background: var(--card);
  border: 1px solid var(--line-soft);
  border-radius: var(--r-xl);
  box-shadow: var(--shadow-card);
  padding: 20px;
}
.card.pad-lg {
  padding: 24px;
}
/* For a section whose empty state is the common case — it should report
   "nothing here" in one line rather than spending a screenful on it. */
.card.slim {
  padding: 13px 16px;
}
.card.slim > div {
  flex-wrap: wrap;
}
.card-head {
  display: flex;
  align-items: baseline;
  gap: 10px;
  margin-bottom: 14px;
}
.card-head .spacer,
.spacer {
  flex: 1;
}

/* page header */
.page-head {
  display: flex;
  align-items: flex-end;
  gap: 16px;
  flex-wrap: wrap;
  margin-bottom: 22px;
}
.page-head .sub {
  color: var(--ink-dim);
  font-size: 14px;
}

/* ------------------------------------------------------------ ways into an account */
/* The Accounts table's Sign-in cell. Two groups — identities somebody else
   vouches for, and credentials this stack holds — separated by spacing and a
   middot, NOT by colour: the palette's colours already mean things (water is
   the reservoir, fault is a fault) and a third meaning bolted onto one of
   them is how a vocabulary stops being readable.
   A 1px `--line-soft` outline was tried first and is what this replaces —
   #e4e8dc on the page's own ground is a hairline nobody can see, so it was
   grouping that existed only in the stylesheet. Members sit at 4px, the
   groups 10px apart; the gap is what does the work. */
.ways {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 10px;
}
.ways-group {
  display: inline-flex;
  flex-wrap: wrap;
  gap: 4px;
}

/* -------------------------------------------------------------- pills/dots */
.pill {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  padding: 2px 9px;
  border-radius: 999px;
  font-size: 12px;
  font-weight: 600;
  white-space: nowrap;
}
.pill.pending,
/* commandStatusCopy() returns this tone for a dose the pot refused. It had
   no rule at all, so a refusal rendered as unstyled text on the one row
   that most needed to look like something. */
.pill.attention {
  background: var(--tint-pending);
  color: var(--attention);
}
/* --forest-ink, not --leaf: leaf over the good tint is 3.5:1 in light and
   3.7:1 in dark. --leaf keeps its job as the dot and the below-target arc,
   where 3:1 against the surface is the bar and it clears it. */
.pill.good {
  background: var(--tint-good);
  color: var(--forest-ink);
}
.pill.fault {
  background: var(--tint-fault);
  color: var(--fault);
}
.pill.neutral {
  background: var(--tint-neutral);
  color: var(--ink-dim);
  font-weight: 500;
}
.pill.water {
  background: var(--tint-water);
  color: var(--water-deep);
}
.pill.forest {
  background: var(--forest);
  color: var(--on-forest);
}
.pill .mono {
  font-weight: 500;
}

.dot {
  display: inline-block;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  flex: none;
}
.dot.good {
  background: var(--leaf);
}
.dot.attention {
  background: var(--attention);
}
.dot.fault {
  background: var(--fault);
}
.dot.unknown {
  background: var(--ink-faint);
}
.dot.water {
  background: var(--water);
}

/* Radio state — is this pot's receiver on right now.
   Each state has a SHAPE as well as a tone, so a sleeping pot reads the
   same on a card, a table row, the app's shelf and a monochrome watch face:
     awake    filled dot with a soft halo — radio on, the reading is seconds old
     asleep   dashed circle — between check-ins, the product working
     missing  circle with a diagonal stroke — nothing here is current
     unknown  plain circle — has never measured anything
   `asleep` is deliberately quiet: a battery pot is asleep ~99% of the time
   and that is the product working, so it must not compete for attention —
   stone, not amber, and not --ink-faint either, because its reading IS
   current. The same four shapes live in grodd-app/src/components/icons.tsx. */
.reach {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  white-space: nowrap;
}
.reach .dot {
  position: relative;
}
.reach.awake .dot {
  box-shadow: 0 0 0 3px var(--tint-good);
}
.reach.asleep .dot {
  background: none;
  border: 1.5px dashed var(--stone);
  width: 9px;
  height: 9px;
}
.reach.unknown .dot {
  background: none;
  border: 1.5px solid var(--stone);
  width: 9px;
  height: 9px;
}
.reach.missing .dot {
  background: none;
  border: 1.5px solid var(--attention);
  width: 9px;
  height: 9px;
}
.reach.missing .dot::after {
  content: '';
  position: absolute;
  left: 50%;
  top: -2px;
  bottom: -2px;
  width: 1.5px;
  margin-left: -0.75px;
  background: var(--attention);
  transform: rotate(45deg);
  border-radius: 1px;
}
.reach.asleep,
.reach.unknown,
.reach.released {
  color: var(--ink-faint);
}
.reach.missing {
  color: var(--attention);
}

/* The plinth — a pot sits on a hairline, and every pot in a room shares it.
   The line does the work a card border used to do without boxing the pots
   into a spreadsheet. Used by the dashboard's shelf; the vessel outlines
   sit flush on it. */
.plinth {
  height: 1px;
  background: var(--plinth);
}
.plinth.hairline {
  background: var(--plinth-hairline);
}
.plinth.heavy {
  background: var(--plinth-heavy);
}

/* The shelf — the fleet as pots on one plinth, under the ring. Each pot is
   one link: the vessel above the line, the name and state below it. The
   line is a border on every foot plus a filler that takes the rest of the
   row, so it runs the full card width and still lands under a wrapped
   second row; that is why `.shelf-foot` has a fixed height — the filler
   can only meet the line if every foot is the same height. */
.shelf {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  row-gap: 18px;
  --shelf-foot-h: 62px;
}
.shelf-pot {
  width: 96px;
  display: flex;
  flex-direction: column;
  align-items: center;
  color: var(--ink);
  /* The bucket's colour, set inline per pot from the ring's SEGMENTS so the
     shelf and the ring can never disagree on what a state looks like. */
  --pot-state: var(--line);
}
.shelf-pot:hover {
  text-decoration: none;
}
.shelf-pot:hover .name {
  text-decoration: underline;
}
/* The pot keeps its own fills — soil in the body, water in the ring — and
   borrows only its OUTLINE from the state.
   These selectors named `svg > path` until 2026-08-25, which was true of
   the vessel and false of the medallion: the medallion's body and rim live
   inside a transform group, so the rules matched nothing and the shelf lost
   its state colour and its dashed stale outline. CSS that matches nothing
   says nothing, so it went unnoticed. `.pot-outline` is emitted by
   `medallion()` for exactly this, and `.pot-gauge` deliberately is NOT
   named here — a stroke on the soil fill draws a line around the dirt. */
.shelf-pot .pot-outline > path,
.shelf-pot .pot-outline > rect {
  stroke: var(--pot-state);
}
/* Dashed for the two quiet buckets, echoing the reach dot's own vocabulary
   (dashed = asleep / unknown): the outline says "nothing current here" the
   same way the dot does. */
.shelf-pot[data-bucket='stale'] .pot-outline > path,
.shelf-pot[data-bucket='uncalibrated'] .pot-outline > path {
  stroke-dasharray: 5 4;
}
.shelf-foot {
  width: 100%;
  height: var(--shelf-foot-h);
  box-sizing: border-box;
  border-top: 1px solid var(--plinth);
  padding: 8px 4px 0;
  text-align: center;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 1px;
}
.shelf-fill {
  flex: 1 0 16px;
  height: var(--shelf-foot-h);
  box-sizing: border-box;
  border-top: 1px solid var(--plinth);
  align-self: flex-end;
}
.shelf-pot .name {
  font-family: var(--font-display);
  font-weight: 600;
  font-size: 13.5px;
  letter-spacing: -0.1px;
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.shelf-pot .state {
  font-size: 11.5px;
  font-weight: 600;
  letter-spacing: 0.04em;
  font-variant-caps: all-small-caps;
  color: var(--pot-state);
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* The healthy bucket's colour is forest, which is also the page's link
   colour — so the state word would read as a second link under the name.
   Stroke stays forest; the word goes faint. */
.shelf-pot[data-bucket='healthy'] .state {
  color: var(--ink-faint);
}
/* The reach word is the catalogue's and can be long ("Har aldrig
   rapporterat"); it clips like the name does rather than pushing the column
   wider, and the dot's title still carries the whole sentence. */
.shelf-pot .reach {
  font-size: 11.5px;
  max-width: 100%;
}
.shelf-pot .reach-word {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
}
@media (max-width: 720px) {
  .shelf-pot {
    width: 84px;
  }
}

/* severity badges (logs) */
.sev {
  display: inline-block;
  padding: 1px 7px;
  border-radius: var(--r-sm);
  font-family: var(--font-mono);
  font-size: 11px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.4px;
}
.sev.critical {
  background: var(--fault);
  color: var(--on-fault);
}
.sev.error {
  background: var(--tint-fault);
  color: var(--fault);
}
.sev.warn {
  background: var(--tint-pending);
  color: var(--attention);
}
.sev.info {
  background: var(--tint-neutral);
  color: var(--ink-dim);
}
.sev.debug {
  background: none;
  border: 1px solid var(--line);
  color: var(--ink-faint);
}

/* ----------------------------------------------------------------- buttons */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 7px;
  padding: 8px 16px;
  border-radius: var(--r-lg);
  font-size: 14px;
  font-weight: 600;
  border: 1px solid transparent;
  transition: transform 0.06s ease, opacity 0.12s ease;
  white-space: nowrap;
}
.btn:active {
  transform: scale(0.98);
  opacity: 0.9;
}
.btn.primary {
  background: var(--forest);
  color: var(--on-forest);
}
.btn.secondary {
  background: var(--card);
  border-color: var(--line);
  color: var(--ink);
  border-radius: var(--r-md);
  font-weight: 500;
}
.btn.secondary:hover {
  border-color: var(--ink-faint);
}
.btn.danger {
  background: var(--card);
  border-color: var(--fault);
  color: var(--fault);
  border-radius: var(--r-md);
  font-weight: 500;
}
.btn.ghost {
  color: var(--ink-dim);
  border-radius: var(--r-md);
  font-weight: 500;
}
.btn.ghost:hover {
  background: var(--tint-neutral);
}
.btn.sm {
  padding: 5px 11px;
  font-size: 13px;
}
.btn[disabled] {
  opacity: 0.55;
  cursor: default;
  transform: none;
}

/* ------------------------------------------------------------------ inputs */
.input,
select.input,
textarea.input {
  font: inherit;
  color: var(--ink);
  background: var(--card);
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  padding: 7px 11px;
  min-width: 0;
}
.input:focus {
  outline: 2px solid var(--water);
  outline-offset: 0;
  border-color: transparent;
}
.input::placeholder {
  color: var(--ink-faint);
}
/* The two sliders on the pot page were the browser default: a 16px-tall
   track, which on a phone is a target you miss. They are not decoration —
   one sets how damp somebody's plant is kept and the other how long the pump
   runs, and a mis-drag changes both. The track stays thin because that is
   what reads as a slider; the TOUCH area is what grows, via padding on the
   element and a thumb big enough to find.

   -webkit- and -moz- thumbs are spelled out separately on purpose: a
   selector list containing a vendor pseudo-element is dropped ENTIRELY by
   the engine that does not recognise it, so combining them would silently
   style neither. */
input[type='range'] {
  -webkit-appearance: none;
  appearance: none;
  width: 100%;
  height: 22px;
  padding: 0;
  margin: 6px 0;
  background: transparent;
  cursor: pointer;
}
input[type='range']::-webkit-slider-runnable-track {
  height: 4px;
  border-radius: 2px;
  background: var(--line);
}
input[type='range']::-moz-range-track {
  height: 4px;
  border-radius: 2px;
  background: var(--line);
}
input[type='range']::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: 20px;
  height: 20px;
  margin-top: -8px; /* centres a 20px thumb on a 4px track */
  border-radius: 50%;
  background: var(--forest);
  border: 2px solid var(--card);
  box-shadow: var(--shadow-card);
}
input[type='range']::-moz-range-thumb {
  width: 20px;
  height: 20px;
  border-radius: 50%;
  background: var(--forest);
  border: 2px solid var(--card);
  box-shadow: var(--shadow-card);
}
input[type='range']:focus-visible {
  outline: 2px solid var(--water);
  outline-offset: 4px;
}

label.field {
  display: flex;
  flex-direction: column;
  gap: 5px;
  font-size: 13px;
  font-weight: 500;
  color: var(--ink-dim);
}
.filters {
  display: flex;
  gap: 8px;
  flex-wrap: wrap;
  align-items: center;
}

/* An inline statement that the page is not showing quite what was asked for.
   Deliberately not a `.sev` pill: those label a record's own severity, and
   this is the page speaking about itself. Tinted rather than loud — nothing
   is wrong, the answer is simply narrower than the question. */
.notice {
  padding: 10px 14px;
  border-radius: 10px;
  border: 1px solid var(--line);
  background: var(--tint-pending);
  color: var(--attention);
  font-size: 13px;
  margin-bottom: 12px;
}

/* chips (filters, tabs) */
.chip {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 5px 12px;
  border-radius: 999px;
  border: 1px solid var(--line);
  background: var(--card);
  color: var(--ink-dim);
  font-size: 13px;
  font-weight: 500;
  white-space: nowrap;
}
.chip:hover {
  color: var(--ink);
  text-decoration: none;
}
.chip.active {
  background: var(--forest);
  border-color: var(--forest);
  color: var(--on-forest);
}
.chip .n {
  font-variant-numeric: tabular-nums;
  opacity: 0.75;
}

/* ------------------------------------------------------------------ tables */
.tbl {
  width: 100%;
  border-collapse: collapse;
  font-size: 13.5px;
}
.tbl th {
  text-align: left;
  /* Small caps, like every other caption since the design pass — these
     were the last uppercase labels in the portal. */
  font-size: 12px;
  font-weight: 600;
  letter-spacing: 0.06em;
  font-variant-caps: all-small-caps;
  text-transform: none;
  color: var(--ink-faint);
  padding: 8px 10px;
  border-bottom: 1px solid var(--line);
  white-space: nowrap;
}
.tbl td {
  padding: 9px 10px;
  border-bottom: 1px solid var(--line-soft);
  vertical-align: middle;
}
/* A sortable header is a button, so it is reachable by keyboard and
   announces itself — the `th` carries aria-sort for the current column. */
.th-sort {
  font: inherit;
  color: inherit;
  letter-spacing: inherit;
  text-transform: inherit;
  padding: 0;
  cursor: pointer;
  white-space: nowrap;
}
.th-sort:hover {
  color: var(--ink);
}
/* --forest-ink resolves per theme, so the active column reads in both
   without an override. --forest itself does not: it is light enough in the
   dark theme to sit under white button text, which is 2.5:1 as ink. */
.th-sort.active {
  color: var(--forest-ink);
}

.tbl tr.row-link {
  cursor: pointer;
}
.tbl tr.row-link:hover td {
  background: var(--tint-neutral);
}
.tbl .day-sep td {
  padding: 14px 10px 4px;
  border-bottom: 0;
}
.tbl-wrap {
  overflow-x: auto;
}

/* ----------------------------------------------------------- page skeleton */
.skel {
  border-radius: var(--r-md);
  background: linear-gradient(100deg, var(--tint-neutral) 40%, transparent 50%, var(--tint-neutral) 60%);
  background-size: 200% 100%;
  animation: shimmer 1.4s infinite linear;
  min-height: 16px;
}
@keyframes shimmer {
  to {
    background-position: -200% 0;
  }
}

/* empty & error states */
.empty {
  display: grid;
  place-items: center;
  gap: 8px;
  text-align: center;
  padding: 56px 24px;
  color: var(--ink-dim);
}
.empty .t-display {
  color: var(--ink);
}
.empty svg {
  color: var(--ink-faint);
  margin-bottom: 6px;
}

/* toast */
#toasts {
  position: fixed;
  /* Fixed to the display, so the home indicator is its problem at every width
     rather than only below the sidebar breakpoint. */
  bottom: calc(20px + env(safe-area-inset-bottom, 0px));
  right: calc(20px + env(safe-area-inset-right, 0px));
  z-index: 90;
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.toast {
  background: var(--card);
  border: 1px solid var(--line);
  border-left: 3px solid var(--forest);
  border-radius: var(--r-md);
  box-shadow: var(--shadow-pop);
  padding: 11px 16px;
  font-size: 14px;
  max-width: 360px;
  animation: toast-in 0.22s ease;
}
.toast.pending {
  border-left-color: var(--attention);
}
.toast.fault {
  border-left-color: var(--fault);
}
@keyframes toast-in {
  from {
    transform: translateY(8px);
    opacity: 0;
  }
}

/* modal / drawer */
.overlay {
  position: fixed;
  inset: 0;
  z-index: 80;
  background: rgba(10, 18, 13, 0.45);
  display: grid;
  place-items: center;
  padding: 20px;
}
.modal {
  background: var(--card);
  border-radius: var(--r-xl);
  box-shadow: var(--shadow-pop);
  padding: 24px;
  width: min(460px, 100%);
  max-height: 85vh;
  overflow-y: auto;
}
.modal h3 {
  margin-bottom: 8px;
}
.modal .actions {
  display: flex;
  justify-content: flex-end;
  gap: 8px;
  margin-top: 18px;
}

/* command palette */
#cmdk .overlay {
  place-items: start center;
  padding-top: 12vh;
}
.cmdk {
  width: min(560px, 100%);
  background: var(--card);
  border-radius: var(--r-lg);
  box-shadow: var(--shadow-pop);
  overflow: hidden;
}
.cmdk input {
  width: 100%;
  border: 0;
  padding: 16px 18px;
  font: inherit;
  font-size: 16px;
  background: none;
  color: var(--ink);
  border-bottom: 1px solid var(--line-soft);
}
.cmdk input:focus {
  outline: none;
}
.cmdk-list {
  max-height: 50vh;
  overflow-y: auto;
  padding: 6px;
}
.cmdk-item {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 9px 12px;
  border-radius: var(--r-md);
  font-size: 14px;
  cursor: pointer;
  color: var(--ink);
}
.cmdk-item .t-faint {
  margin-left: auto;
  font-size: 12px;
}
.cmdk-item.sel {
  background: var(--tint-forest);
}
.cmdk-item svg {
  color: var(--ink-faint);
  flex: none;
}
.cmdk-group {
  padding: 10px 12px 3px;
}

/* ------------------------------------------------------------- fleet hero */
.hero {
  display: flex;
  gap: 36px;
  align-items: center;
  flex-wrap: wrap;
}
.hero-ring {
  flex: none;
}
.hero-copy {
  flex: 1;
  /* The 260px is the point below which the headline stops being a sentence and
     starts being a column of words — it exists to make the hero WRAP rather
     than squeeze. But a bare `min-width` is a floor the element keeps even
     when its container is narrower than the floor, which is how the dashboard
     came to be 47px wider than a 320px screen. `min()` keeps the intent and
     surrenders it to the container, which is the one case where the intent
     cannot be satisfied anyway. */
  min-width: min(260px, 100%);
  display: flex;
  flex-direction: column;
  gap: 14px;
}
.hero-counts {
  display: flex;
  gap: 10px;
  flex-wrap: wrap;
}
.hero-count {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 7px 14px;
  border-radius: 999px;
  border: 1px solid var(--line);
  background: var(--card);
  font-size: 13.5px;
  font-weight: 500;
  color: var(--ink-dim);
}
.hero-count:hover {
  text-decoration: none;
  border-color: var(--ink-faint);
  color: var(--ink);
}
.hero-count b {
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  color: var(--ink);
}

/* stat tiles */
.tiles {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
  gap: 12px;
}
.tile {
  background: var(--card);
  border: 1px solid var(--line-soft);
  border-radius: var(--r-lg);
  padding: 14px 16px;
  display: flex;
  flex-direction: column;
  gap: 4px;
}
a.tile:hover {
  text-decoration: none;
  border-color: var(--ink-faint);
}
.tile .t-stat {
  color: var(--ink);
}
.tile.warn .t-stat {
  color: var(--attention);
}
.tile.bad .t-stat {
  color: var(--fault);
}
.tile.water .t-stat {
  color: var(--water-deep);
}

/* dashboard layout */
.grid-2 {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 16px;
}
.grid-main-side {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 340px;
  gap: 16px;
  align-items: start;
}
/* A grid item's automatic minimum is `auto`, which means it refuses to become
   narrower than its own min-content however narrow the track is. The left
   track already says `minmax(0, 1fr)` — this is the same statement for the
   items themselves, and the right-hand one needs it too once the columns
   collapse to `1fr` below 1080px.

   Without it the Activity card's min-content — a status dot, an ellipsised
   sentence and a timestamp that may not wrap — held the whole column at 335px
   on a 320px screen and the dashboard scrolled sideways. The children already
   know how to truncate; they were never given the chance. Found at 320px in
   Swedish, where "för 1 min sen" is twice the width of "1m ago". */
.grid-main-side > * {
  min-width: 0;
}
.stack {
  display: flex;
  flex-direction: column;
  gap: 16px;
}
.section-gap {
  margin-top: 26px;
}

/* ---------------------------------------------------------------- pot grid */
.pot-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
  gap: 14px;
}
.pot-card {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  padding: 22px 14px 16px;
  background: var(--card);
  border: 1px solid var(--line-soft);
  border-radius: var(--r-xl);
  box-shadow: var(--shadow-card);
  position: relative;
  color: var(--ink);
  text-align: center;
}
.pot-card:hover {
  text-decoration: none;
  border-color: var(--ink-faint);
}
.pot-card .corner-tl {
  position: absolute;
  top: 12px;
  left: 12px;
}
.pot-card .corner-tr {
  position: absolute;
  top: 12px;
  right: 12px;
}
.pot-card .name {
  font-family: var(--font-display);
  font-weight: 600;
  font-size: 19px;
  letter-spacing: -0.1px;
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.pot-card .summary {
  display: flex;
  align-items: center;
  gap: 7px;
  font-size: 13px;
  color: var(--ink-dim);
}
.pot-card .readings {
  display: flex;
  gap: 14px;
  margin-top: 6px;
}
.pot-card .readings > div {
  display: flex;
  flex-direction: column;
  gap: 1px;
  align-items: center;
}
.pot-card .readings .v {
  font-size: 15px;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}
.pot-card .readings .v.water {
  color: var(--water-deep);
}
.pot-card .readings .v.na {
  color: var(--ink-faint);
  font-weight: 400;
}
/* The firmware line. Recessive on purpose — it is the answer to "which
   build is this one on", asked while looking at a whole fleet, not
   something to read on every card every time. A hairline separates it
   from the readings so it is a footnote rather than a fourth reading. */
.pot-card .fw {
  margin-top: 10px;
  padding-top: 9px;
  border-top: 1px solid var(--line-soft);
  width: 100%;
  font-family: var(--font-mono);
  font-size: 11px;
  color: var(--ink-dim);
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* Lowercase and unshouted. A newer release existing is worth knowing and is
   not an alarm — and amber that appears on every card is amber nobody reads.
   Same phrasing as the Firmware page, so it is one idea with one name. */
.pot-card .fw .fw-new {
  font-family: var(--font-sans);
  font-size: 11px;
  color: var(--attention);
}
.pot-card.muted .ring-wrap,
.muted-vis {
  opacity: 0.45;
}

/* pot table view */
.pots-toolbar {
  display: flex;
  gap: 8px;
  align-items: center;
  flex-wrap: wrap;
  margin-bottom: 14px;
}

/* --------------------------------------------------------------- pot page */
.pot-hero {
  display: flex;
  flex-direction: column;
  gap: 6px;
  margin-bottom: 20px;
}
.pot-hero .meta {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
  color: var(--ink-dim);
  font-size: 14px;
}
.pot-tiles {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 16px;
}
.sensor-tile {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10px;
  padding: 22px;
}
.sensor-tile .foot {
  font-size: 13px;
  color: var(--ink-dim);
  text-align: center;
}
/* The pot page's one drawing, with both channels' words beneath it.
   Two tiles collapsed into one on 2026-08-24 — a soil ring beside a water
   vessel is two instruments for one pot — and the sentences each tile
   carried came with it rather than being lost. Two columns on a wide page,
   one on a narrow one, so the captions never crush. */
/* The pot page's hero: the drawing, and beside it the two numbers it draws.
   A centred column before — a 150px circle alone in a full-width card, which
   is a lot of nothing on a wide screen. A row uses the width and matches the
   app's pot page, so both products open on the same shape. */
.pot-medallion {
  display: flex;
  flex-direction: row;
  align-items: center;
  gap: 28px;
  padding: 24px 26px;
}
.pot-medallion > div:first-child {
  flex: none;
}
.pot-medallion .med-foot {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 26px;
  flex: 1;
  min-width: 0;
  /* Top, not stretch. One channel can have a number where the other has
     only a sentence — an uncalibrated probe beside a measured reservoir —
     and centring each column then floats the two captions at different
     heights, which reads as a layout accident rather than as one pot
     missing one sensor. */
  align-items: start;
}
.pot-medallion .med-fact {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
}
.pot-medallion .med-num {
  font-family: var(--font-display);
  font-weight: 600;
  font-size: 40px;
  line-height: 1.02;
  letter-spacing: -0.5px;
  font-variant-numeric: tabular-nums;
  color: var(--ink);
}
.pot-medallion .med-num.water {
  color: var(--water-deep);
}
.pot-medallion .med-unit {
  font-size: 20px;
  color: var(--ink-faint);
  margin-left: 1px;
}
.pot-medallion .med-fact .foot {
  font-size: 13.5px;
  color: var(--ink-dim);
  text-align: left;
  margin-top: 4px;
  max-width: 34ch;
}
/* Left, all three lines. These were centred for the old stacked layout and
   survived the change to a row, so the caption sat centred over a
   left-aligned number and a left-aligned sentence — three different left
   edges in one column. */
.pot-medallion .med-foot .t-caption {
  margin-bottom: 2px;
  text-align: left;
}

/* Below the fold of a narrow window the row would crush both columns, so it
   goes back to the stack it was, medallion centred above its facts. */
@media (max-width: 720px) {
  .pot-medallion {
    flex-direction: column;
    align-items: center;
    gap: 18px;
  }
  .pot-medallion .med-foot {
    width: 100%;
    gap: 18px;
  }
  .pot-medallion .med-fact .foot,
  .pot-medallion .med-foot .t-caption {
    text-align: center;
  }
  .pot-medallion .med-fact {
    align-items: center;
  }
}
@media (max-width: 560px) {
  .pot-medallion .med-foot {
    grid-template-columns: 1fr;
    gap: 12px;
  }
}
.vitals {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
  gap: 1px;
  background: var(--line-soft);
  border: 1px solid var(--line-soft);
  border-radius: var(--r-lg);
  overflow: hidden;
}
.vitals > div {
  background: var(--card);
  padding: 12px 14px;
  display: flex;
  flex-direction: column;
  gap: 3px;
}
.vitals .v {
  font-family: var(--font-display);
  font-weight: 600;
  font-size: 20px;
  line-height: 24px;
  letter-spacing: -0.2px;
  font-variant-numeric: tabular-nums;
}
.vitals .v.word {
  font-family: var(--font-sans);
  font-size: 13px;
  font-weight: 500;
}
.vitals .v.na {
  color: var(--ink-faint);
  font-weight: 400;
}

/* actions row */
.actions-row {
  display: flex;
  gap: 8px;
  flex-wrap: wrap;
}

/* timeline */
.timeline {
  display: flex;
  flex-direction: column;
}
.tl-item {
  display: grid;
  grid-template-columns: 52px 20px 1fr;
  gap: 6px;
  min-height: 44px;
}
.tl-time {
  font-size: 12px;
  color: var(--ink-faint);
  font-variant-numeric: tabular-nums;
  text-align: right;
  padding-top: 2px;
}
.tl-rail {
  position: relative;
  display: flex;
  justify-content: center;
}
.tl-rail::before {
  content: '';
  position: absolute;
  top: 14px;
  bottom: -6px;
  width: 1px;
  background: var(--line);
}
.tl-item:last-child .tl-rail::before {
  display: none;
}
.tl-dot {
  position: relative;
  z-index: 1;
  width: 9px;
  height: 9px;
  border-radius: 50%;
  margin-top: 4px;
  background: var(--ink-faint);
  box-shadow: 0 0 0 2px var(--card);
}
.tl-dot.water {
  background: var(--water);
}
.tl-dot.good {
  background: var(--leaf);
}
.tl-dot.attention {
  background: var(--attention);
}
.tl-dot.fault {
  background: var(--fault);
}
.tl-body {
  padding-bottom: 14px;
  font-size: 13.5px;
  min-width: 0;
}
.tl-body .why {
  color: var(--ink-dim);
  font-size: 12.5px;
}
.tl-day {
  padding: 12px 0 6px 72px;
}

/* activity feed */
.feed-item {
  display: flex;
  align-items: baseline;
  gap: 10px;
  padding: 8px 2px;
  border-bottom: 1px solid var(--line-soft);
  font-size: 13.5px;
}
.feed-item:last-child {
  border-bottom: 0;
}
.feed-item .when {
  margin-left: auto;
  flex: none;
  font-size: 12px;
  color: var(--ink-faint);
  font-variant-numeric: tabular-nums;
}
.feed-item.fresh {
  animation: feed-in 1.2s ease forwards;
}
@keyframes feed-in {
  from {
    background: var(--tint-good);
  }
  to {
    background: transparent;
  }
}

/* issues */
.issue {
  display: flex;
  gap: 14px;
  align-items: flex-start;
  padding: 14px 16px;
  border: 1px solid var(--line-soft);
  border-radius: var(--r-lg);
  background: var(--card);
}
.issue.critical {
  border-left: 3px solid var(--fault);
}
.issue.warning {
  border-left: 3px solid var(--attention);
}
.issue.resolved {
  opacity: 0.72;
}
.issue .body {
  flex: 1;
  min-width: 0;
}
.issue .what {
  font-weight: 600;
  font-size: 14.5px;
}
.issue .why {
  color: var(--ink-dim);
  font-size: 13px;
  margin-top: 2px;
}
.issue .act {
  color: var(--forest-ink);
  font-size: 13px;
  font-weight: 500;
  margin-top: 6px;
}

/* A resolved issue on one line inside one surface. The card shape is for an
   open row, which needs room for its why and its next action; a resolved
   one has neither — it happened and it ended — and a full card at .72
   opacity reads as an open issue somebody forgot about. The history is a
   ledger: dot, what, pot, opened → ended, Delete. The row keeps `.resolved`
   for its opacity; this rule only takes the box away and puts the rows on
   the same hairline the rest of the design stands things on. */
.issue.compact {
  align-items: center;
  gap: 10px;
  padding: 9px 0;
  border: 0;
  border-bottom: 1px solid var(--plinth-hairline);
  border-radius: 0;
  background: transparent;
  font-size: 13.5px;
}
.issue.compact:last-child {
  border-bottom: 0;
}
.issue.compact .what {
  font-size: 13.5px;
  font-weight: 500;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* firmware */
.fw-release {
  display: flex;
  flex-direction: column;
  gap: 10px;
}
.fw-bar {
  height: 8px;
  border-radius: 999px;
  background: var(--tint-neutral);
  overflow: hidden;
}
.fw-bar > div {
  height: 100%;
  border-radius: 999px;
  background: var(--forest);
  transition: width 0.6s cubic-bezier(0.2, 0.7, 0.3, 1);
}

/* system page */
.sys-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
  gap: 16px;
  align-items: start;
}
.sys-row {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 7px 0;
  border-bottom: 1px solid var(--line-soft);
  font-size: 13.5px;
  /* A key/value row is two halves and neither may cover the other. Without
     this the row is a single line whatever it holds: a flex item's automatic
     minimum size is its min-content size, so the right half — a strip of
     selects and buttons, none of which shrinks — refused to give ground and
     the left half was crushed past zero. Its contents then painted OUTSIDE
     their own box, which is why a plant name sat on top of a dropdown at
     375px, and why a service's second line broke one character per line: an
     `overflow-wrap: anywhere` string in a column about one character wide
     does exactly that, correctly. Both halves declare `min-width: 0` below
     so they CAN shrink, and the row wraps so the right half takes its own
     line rather than shrinking to nothing. */
  flex-wrap: wrap;
}
.sys-row:last-child {
  border-bottom: 0;
}
.sys-row > .k,
.sys-row > .v {
  min-width: 0;
  max-width: 100%;
}
.sys-row .k {
  color: var(--ink-dim);
}
.sys-row .v {
  margin-left: auto;
  font-weight: 500;
  font-variant-numeric: tabular-nums;
  text-align: right;
}
/* The right half when it is a cluster of controls rather than a reading.
   A class rather than the inline styles this replaces, because an inline
   `justify-content: flex-end` cannot be undone by the phone breakpoint
   below without `!important`, and a stylesheet that has to shout at its own
   markup is a stylesheet with two authors. */
.sys-row .v.v-acts {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-wrap: wrap;
  justify-content: flex-end;
  font-weight: 400;
}

/* ------------------------------------------------ dropdowns and chip groups
   `.sel` and `.chips` were written into the simulator panel's markup and
   never into this stylesheet — fifteen selects and two groups carrying class
   names that matched no rule. So every dropdown on the System page was a raw
   browser control: it took none of the portal's border, radius or ink, and,
   because the touch-target block below lists `.input` and `select.input` by
   name, none of its 40px minimum either. On a phone they were the 27px
   native default, which is the size this project already measured as "legal
   and still fiddly" once. */
.sel {
  font: inherit;
  font-size: 13.5px;
  color: var(--ink);
  background: var(--card);
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  padding: 6px 9px;
  min-width: 0;
  max-width: 100%;
}
.sel.sm {
  font-size: 13px;
}
.sel:focus-visible {
  outline: 2px solid var(--water);
  outline-offset: 0;
}
.chips {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 6px;
}

/* --------------------------------------------------------- simulated fleet
   The panel is two cards: the fleet's own knobs, and the pots.

   It used to be one card of `.sys-row`s, each a flex line with an identity
   strip on the left and eight controls pushed to the right — dense on a
   desktop and unusable on a phone, where the two halves could not both fit
   and neither would shrink, so a plant name painted on top of a dropdown.

   Everything here is a grid with a minimum column, so the same markup is
   four columns on a monitor and one on a phone with nothing to override at
   a breakpoint. A `minmax()` track that runs out of room wraps; two flex
   halves that run out of room overlap. That difference is the whole fix. */
.sim-fleet {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
  gap: 12px;
  padding: 12px;
  border: 1px solid var(--line-soft);
  border-radius: var(--r-lg);
  background: var(--surface);
}
.sim-knob {
  display: flex;
  flex-direction: column;
  gap: 5px;
  min-width: 0;
}
.sim-knob-ctl {
  display: flex;
  align-items: center;
  gap: 6px;
  flex-wrap: wrap;
  min-width: 0;
}

.sim-toolbar {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
  margin-bottom: 14px;
}
.sim-toolbar #sim-q {
  flex: 1 1 200px;
  min-width: 0;
  font-size: 13.5px;
}

.sim-pots {
  display: flex;
  flex-direction: column;
  gap: 12px;
}
.sim-pot {
  border: 1px solid var(--line-soft);
  border-left: 3px solid var(--line);
  border-radius: var(--r-lg);
  padding: 13px 14px;
  background: var(--surface);
  min-width: 0;
}
/* A pot that is not running is muted, never alarmed — the house rule for
   offline, applied to the one list where "not running" is a normal state an
   operator has just caused. */
.sim-pot.stopped {
  opacity: 0.72;
  background: transparent;
}
/* The edge carries the worst finding on the wire. Only the two loud tones
   reach it; see the comment where `worst` is computed. */
.sim-pot.diverging.fault {
  border-left-color: var(--fault);
}
.sim-pot.diverging.attention {
  border-left-color: var(--attention);
}
.sim-pot-head {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-wrap: wrap;
  min-width: 0;
}
.sim-pot-head .spacer {
  flex: 1 1 0;
  min-width: 0;
}
.sim-id {
  font-size: 12.5px;
  font-weight: 600;
}

.sim-vitals {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(122px, 1fr));
  gap: 8px 14px;
  margin-top: 10px;
}
.sim-vital {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
}
.sim-vital .num {
  font-size: 13.5px;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  /* The target note rides inside the number's own span and must be able to
     fall to a second line rather than widen the track past its minimum. */
  overflow-wrap: anywhere;
}
.sim-vital .num .t-faint {
  font-weight: 400;
}

.sim-notes {
  display: flex;
  flex-wrap: wrap;
  gap: 4px 12px;
  margin-top: 8px;
  font-size: 12px;
  color: var(--ink-faint);
}
.sim-latched {
  color: var(--fault);
}

/* What the pot published against what it is. */
.sim-diverge {
  display: flex;
  flex-direction: column;
  gap: 6px;
  margin-top: 10px;
  padding: 9px 11px;
  border-radius: var(--r-md);
  background: var(--tint-neutral);
}
.sim-diverge-row {
  display: grid;
  grid-template-columns: 10px 1fr;
  gap: 3px 8px;
  align-items: baseline;
  font-size: 12.5px;
}
/* The finding's own words start under its dot, not beside it — a second
   column for the explanation would be two narrow columns on a phone. */
.sim-diverge-row .t-faint {
  grid-column: 2;
  font-size: 12px;
}
.sim-diverge-row .dot {
  align-self: center;
}

.sim-truth {
  margin-top: 10px;
  font-size: 12.5px;
}
.sim-truth > summary {
  color: var(--ink-dim);
  cursor: pointer;
  padding: 2px 0;
}
.sim-truth-body {
  padding-top: 8px;
}
.sim-truth-tbl {
  font-size: 12.5px;
}
.sim-truth-facts {
  display: flex;
  flex-direction: column;
  gap: 6px;
  margin-top: 10px;
  min-width: 0;
}
/* The payload verbatim. `pre` is the one element here that genuinely cannot
   wrap — a JSON object broken at spaces is no longer the thing that went on
   the wire — so it scrolls inside its own box rather than pushing the page
   sideways, which is the rule every wide block on this page follows. */
.sim-payload {
  margin: 0;
  padding: 9px 11px;
  border-radius: var(--r-md);
  background: var(--tint-neutral);
  font-size: 11.5px;
  line-height: 1.45;
  overflow-x: auto;
  max-height: 260px;
  overflow-y: auto;
}

.sim-controls {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(158px, 1fr));
  gap: 10px 12px;
  margin-top: 12px;
}
.sim-ctl {
  display: flex;
  flex-direction: column;
  gap: 4px;
  min-width: 0;
}
.sim-ctl-pair {
  display: flex;
  align-items: center;
  gap: 6px;
  min-width: 0;
}
.sim-ctl-pair .sel {
  flex: 1 1 auto;
}
.sim-ctl-pair .btn {
  flex: 0 0 auto;
}
.sim-acts {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin-top: 12px;
  padding-top: 11px;
  border-top: 1px solid var(--line-soft);
}
.sim-pot.stopped .sim-acts {
  border-top: 0;
  padding-top: 0;
}

.sim-add-row {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(158px, 1fr));
  gap: 10px 12px;
  align-items: end;
  margin-top: 16px;
  padding-top: 14px;
  border-top: 1px solid var(--line-soft);
}
.sim-add-row .sim-acts {
  margin-top: 0;
  padding-top: 0;
  border-top: 0;
}

/* charts */
.chart-card .t-caption {
  margin-bottom: 2px;
}
.chart-sub {
  font-size: 12.5px;
  color: var(--ink-dim);
  margin-bottom: 10px;
}
.chart-empty {
  display: grid;
  place-items: center;
  height: 120px;
  color: var(--ink-faint);
  font-size: 13px;
  font-style: italic;
}
.chart-tip {
  position: fixed;
  z-index: 70;
  pointer-events: none;
  background: var(--card);
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  box-shadow: var(--shadow-pop);
  padding: 7px 10px;
  font-size: 12.5px;
  white-space: nowrap;
}

/* pager */
.pager {
  display: flex;
  /* Five children — two buttons, a range, a page-size select and a status
     phrase — on a row that could not wrap. It fits from about 340px up, which
     is why a 375px audit passed it and a phone with Display Zoom on (320px)
     scrolled sideways. Wrapping is the whole fix; nothing here has an order
     that has to survive on one line. */
  flex-wrap: wrap;
  align-items: center;
  gap: 10px;
  margin-top: 14px;
  font-size: 13px;
  color: var(--ink-dim);
}

/* detail rows (logs) */
.log-detail {
  background: var(--surface);
  border-radius: var(--r-md);
  padding: 14px 16px;
  font-size: 13px;
}
.log-detail pre {
  margin: 8px 0 0;
  padding: 10px 12px;
  background: var(--card);
  border: 1px solid var(--line-soft);
  border-radius: var(--r-md);
  overflow-x: auto;
  font-size: 12px;
  line-height: 1.5;
}
.kv {
  display: grid;
  grid-template-columns: max-content 1fr;
  gap: 3px 16px;
}
.kv dt {
  color: var(--ink-faint);
}
.kv dd {
  margin: 0;
  font-variant-numeric: tabular-nums;
}

/* sign-in card */
.signin-card {
  width: min(360px, 100%);
  display: flex;
  flex-direction: column;
  gap: 14px;
  text-align: center;
}
.signin-card form {
  display: flex;
  flex-direction: column;
  gap: 10px;
  text-align: left;
}
.signin-err {
  color: var(--fault);
  font-size: 13px;
  min-height: 18px;
}

/* -------------------------------------------------- the display's own edges

   `index.html` sets `viewport-fit=cover`. That is opt-in and it is not free:
   it extends the layout under the notch, the rounded corners and the home
   indicator, and hands the page responsibility for insetting itself. Nothing
   in this stylesheet ever did — there was no `env(safe-area-inset-*)` anywhere
   — so this was latent from the day the portal was written.

   It does not show in portrait Safari with the toolbar up. Measured on an
   iPhone 17 Pro: all four insets are 0, because the browser's own chrome is
   already sitting in the unsafe area. It shows in **landscape**, where the
   notch side is ~59px, and in portrait the moment the toolbar hides on scroll
   and the home indicator's ~34px becomes the page's problem.

   The fixed and sticky furniture is what suffers, because `main`'s own padding
   cannot help it: the drawer's Sign out button, the toasts, the topbar.

   `max()` rather than adding, so the existing padding is a floor and a device
   with no insets renders byte-identically to before.

   The declarations themselves are edited in place, below and above, rather
   than collected here as overrides: `main`'s padding is a shorthand set inside
   the responsive block, and a longhand override written *before* it in the
   file loses the cascade while looking perfectly correct. */

/* ------------------------------------------------------------- responsive */
@media (max-width: 1080px) {
  .shell {
    grid-template-columns: 1fr;
  }
  .sidebar {
    position: fixed;
    z-index: 60;
    left: 0;
    top: 0;
    bottom: 0;
    width: min(300px, 84vw);
    background: var(--ground);
    transform: translateX(-102%);
    transition: transform 0.22s ease;
    box-shadow: none;
    height: 100dvh;
    /* The drawer is fixed to the physical left edge and runs the full height,
       so it gets no help from main's padding. `.sidebar-foot` — Sign out, and
       the connection state — is the last thing in it, which is exactly where
       the home indicator lands. */
    padding-left: max(18px, env(safe-area-inset-left, 0px));
    padding-bottom: max(18px, env(safe-area-inset-bottom, 0px));
  }
  body.nav-open .sidebar {
    transform: none;
    box-shadow: var(--shadow-pop);
  }
  .scrim {
    display: none;
  }
  body.nav-open .scrim {
    display: block;
    position: fixed;
    inset: 0;
    z-index: 50;
    background: rgba(10, 18, 13, 0.4);
  }
  .topbar {
    display: flex;
    padding-left: max(14px, env(safe-area-inset-left, 0px));
    padding-right: max(14px, env(safe-area-inset-right, 0px));
  }
  main {
    padding: 20px max(16px, env(safe-area-inset-right, 0px)) calc(56px + env(safe-area-inset-bottom, 0px))
      max(16px, env(safe-area-inset-left, 0px));
  }
  .grid-main-side {
    grid-template-columns: 1fr;
  }
}
@media (max-width: 720px) {
  .pot-tiles,
  .grid-2 {
    grid-template-columns: 1fr;
  }
  .hero {
    gap: 20px;
  }
  .t-hero {
    font-size: 26px;
  }
  .tl-item {
    grid-template-columns: 44px 18px 1fr;
  }
  .tl-day {
    padding-left: 62px;
  }
  #toasts {
    left: max(16px, env(safe-area-inset-left, 0px));
    right: max(16px, env(safe-area-inset-right, 0px));
    bottom: calc(16px + env(safe-area-inset-bottom, 0px));
  }
  .toast {
    max-width: none;
  }

  /* A key/value row becomes a key ABOVE its value.

     Wrapping alone is not enough here and the reason is worth writing down:
     wrapping decides whether the two halves share a line, and `margin-left:
     auto` plus `text-align: right` still push the second one to the far
     edge once it has its own. On a 375px screen that produced a column of
     right-aligned fragments with a ragged left edge, under a left-aligned
     label — two alignments in one row, which reads as two unrelated things.
     So on a phone the row is a stack and everything in it starts at the
     same left edge. `.v` keeps its tabular figures: a column of readings
     still lines up digit for digit. */
  .sys-row {
    flex-direction: column;
    align-items: stretch;
    gap: 6px;
  }
  .sys-row .v,
  .sys-row .v.v-acts {
    margin-left: 0;
    text-align: left;
    justify-content: flex-start;
  }
}

/* Reduced motion is a request for no motion, not for fast motion.

   This block used to clamp every duration to 1 ms, which is not the same
   thing. A zero-length animation still runs: it still paints its first
   keyframe, still fires animationstart and animationend, and — the case
   that matters here — anything animating a value up from nothing still
   starts at nothing. The vessel's water line is drawn from its own height
   (`.vfill` in ui.mjs is the hook a rise would hang on), and a 1 ms rise
   from an empty pot is a reservoir level the pot never reported, however
   briefly. The feed highlight flashed green on every new row, and the
   skeleton's infinite shimmer ran its loop as fast as the compositor would
   allow, which is a battery cost paid by the reader who asked for less.

   So: no animation and no transition at all, and then the resting state
   spelled out for the two places whose resting state is not the default. */
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation: none !important;
    transition: none !important;
    scroll-behavior: auto !important;
  }

  /* feed-in ends transparent, so a new row simply appears with no highlight
     to fade — which is the final state, arrived at directly. */
  .feed-item.fresh {
    background: transparent;
  }

  /* The shimmer gradient is transparent through its middle, so a frozen
     .skel would be a bar with a hole in it. The flat tint is what the
     shimmer sweeps across. Longhands, not the `background` shorthand: a
     shorthand carrying a var() cannot be reserialized, so it reads as a row
     of empty sub-properties in every tool that inspects it. */
  .skel {
    background-image: none;
    background-color: var(--tint-neutral);
  }
}

[hidden] {
  display: none !important;
}


/* ------------------------------------------------ touch targets on a phone
   A pointer is not a finger. Measured on the pot page at 375px, the window
   selects came out 27px tall and the small buttons 32px — legal under WCAG
   2.5.8, which asks for 24px, and still fiddly on the device this fleet is
   actually checked from.

   Raised here rather than in the base rules because a mouse does not need
   it, and 40px controls on a dense desktop table would cost rows for nothing.
   40px rather than Apple's 44: these sit in wrapping rows of chips and
   filters, and 44 pushes a three-chip row onto two lines, which loses more
   than the four pixels gain. */
@media (pointer: coarse), (max-width: 720px) {
  .btn,
  .btn.sm,
  .chip,
  .input,
  select.input,
  /* `.sel` is the System page's dropdown and was missing from this list for
     as long as it was missing from the stylesheet — fifteen controls, every
     one of them the browser's 27px default on the device this fleet is
     actually checked from. Both spellings, because the rule below needs the
     element and this one does not. */
  .sel,
  select.sel {
    min-height: 40px;
  }
  .btn.sm,
  .chip {
    padding-top: 6px;
    padding-bottom: 6px;
  }
  /* Selects keep their own arrow, so padding alone leaves the text
     off-centre — the flex box does the centring for the others and a select
     is not a flex container. */
  select.input,
  select.sel {
    padding-top: 0;
    padding-bottom: 0;
  }
  /* A default checkbox is 13px. These are the toggles that decide whether a
     pot waters itself at all and whether its light is on, so a miss is not
     cosmetic. The label beside each one is already a click target — this is
     for the person who aims at the box. */
  input[type='checkbox'],
  input[type='radio'] {
    width: 22px;
    height: 22px;
    accent-color: var(--forest);
  }
  /* A fold is a control and was never sized like one. Bare `summary`, not
     `summary:has(> .disclose)`: that selector is scoped on purpose to the
     three chevron folds so the small native-marker details keep their
     triangles, and reusing it here would have covered half the folds while
     reading as if it covered all of them — the pack-image and version
     details would stay at their text height.

     Height only — NOT `display: flex`. A flex summary erases the native
     disclosure triangle (see the Collapsible sections block), which is the
     very thing the scoping above protects, and the whole box is the click
     target either way. The card-head folds already centre themselves. */
  summary {
    min-height: 40px;
  }
  /* The sortable header's own button, filling the cell it already sits in
     rather than growing every header row on a phone. `.tbl th` pads 8px 10px
     and the button pads none of it, so today the tappable area is the text —
     the negative margin reclaims the padding that is already there. */
  .th-sort {
    display: block;
    width: 100%;
    text-align: inherit;
    padding: 12px 10px;
    margin: -8px -10px;
  }
}

/* ------------------------------------------------------- command history row
   A pill carries a word or two, and `white-space: nowrap` is right for that.
   Three of commandStatusCopy()'s answers are whole sentences, because the
   honest version of what those acks mean does not fit in a word: a
   factory_reset ack arrives before the erase, a renew_cert ack precedes the
   reboot that proves it, and a calibrate ack is not the confirmation at all
   — the channel appearing in `info.sensors` is. Reported 2026-08-13 as
   "Captured — confirmed" going outside the box, which it was: the row could
   neither wrap nor shrink it.

   So the row wraps and the pill is allowed to run to two lines here. The
   words are the part that had to survive. */
.cmd-row {
  display: flex;
  flex-wrap: wrap;
  gap: 4px 8px;
  align-items: baseline;
  font-size: 13px;
}
.cmd-kind {
  white-space: nowrap;
}
/* A command row's second line: the refused dose's next action, under the
   pill rather than beside it. */
.cmd-row .cmd-why {
  flex-basis: 100%;
  font-size: 12px;
  color: var(--ink-dim);
}
.cmd-row .pill {
  white-space: normal;
  /* Not 999px: a two-line lozenge reads as a mistake, where a rounded
     rectangle reads as a label that happens to be long. */
  border-radius: 11px;
  flex: 1 1 auto;
  min-width: 0;
  text-align: left;
}
/* `margin-left: auto` rather than a .spacer element, so that when the pill
   takes a second line the timestamp stays at the end of the row instead of
   being pushed out of the card by a flex:1 sibling that cannot shrink. */
.cmd-when {
  margin-left: auto;
  white-space: nowrap;
  font-size: 11.5px;
}

/* ------------------------------------------------------- calibration dialog
   Rebuilt 2026-08-13 from four buttons in one right-aligned row, where the
   destructive one sat between the two ordinary ones and all four wrapped
   into a stack at 460px. The two captures are the content here, not the
   footer: each is a choice about a physical condition, so each carries that
   condition rather than leaving it in a paragraph above. */
.cal-state {
  margin-top: 12px;
  padding: 9px 12px;
  border-radius: var(--r-md, 10px);
  background: var(--tint-pending);
  color: var(--attention);
  font-size: 13px;
  line-height: 1.45;
}
.cal-state.ok {
  background: var(--tint-good);
  color: var(--forest-ink);
}
.cal-choices {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 10px;
  margin-top: 14px;
}
/* One column below the modal's own width, because two 200px cards holding
   a sentence each is how "Probe in freshly watered soil" becomes four
   lines of two words. */
@media (max-width: 520px) {
  .cal-choices {
    grid-template-columns: 1fr;
  }
}
.cal-choice {
  display: flex;
  flex-direction: column;
  gap: 3px;
  align-items: flex-start;
  text-align: left;
  padding: 12px 14px;
  border: 1px solid var(--line);
  border-radius: var(--r-lg, 12px);
  background: var(--surface, transparent);
  cursor: pointer;
  transition: border-color 0.12s ease, background 0.12s ease;
}
.cal-choice:hover {
  border-color: var(--leaf);
  background: var(--tint-good);
}
.cal-choice:focus-visible {
  outline: 2px solid var(--leaf);
  outline-offset: 2px;
}
.cal-choice-title {
  font-weight: 600;
  font-size: 14px;
  color: var(--ink);
}
.cal-choice-hint {
  font-size: 12px;
  color: var(--ink-dim);
  line-height: 1.4;
}
.cal-note {
  font-size: 12px;
  margin-top: 12px;
  line-height: 1.5;
}
/* Clearing sits opposite Cancel with a rule above it, so the destructive
   control is nowhere near the two a reader came here to press. */
.cal-footer {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 8px;
  margin-top: 16px;
  padding-top: 14px;
  border-top: 1px solid var(--line);
}
.cal-clear {
  color: var(--fault);
}

/* ---------------------------------------------------------- auto-update row
   Added 2026-08-13: the API has carried auto_update per pot since the
   onboarding work and /api/pots returns it, but no portal page rendered it,
   so the fleet console could push a release to a pot by hand while being
   unable to see whether that pot installs them by itself. */
.auto-update {
  display: flex;
  gap: 10px;
  align-items: flex-start;
  margin-top: 14px;
  padding-top: 14px;
  border-top: 1px solid var(--line);
  cursor: pointer;
}
.auto-update input {
  margin-top: 2px;
  flex: none;
}
.auto-update-title {
  display: block;
  font-size: 13.5px;
  font-weight: 600;
  color: var(--ink);
}
.auto-update-hint {
  display: block;
  font-size: 12px;
  color: var(--ink-dim);
  line-height: 1.5;
  margin-top: 2px;
}

/* ------------------------------------------------------------ pending detail
   The badge said a change was unconfirmed and never which one, so a pot stuck
   on "pending" could not be told apart from one waiting on a sleeping radio.
   Read from the twin: the key, what the pot last confirmed, and what was
   asked for. */
.pending-detail {
  margin: 10px 0 2px;
  padding: 10px 12px;
  border-radius: var(--r-md, 10px);
  background: var(--tint-pending);
}
.pending-line {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 6px;
  font-size: 12.5px;
  line-height: 1.6;
}
.pending-key {
  font-weight: 600;
  color: var(--attention);
  margin-right: 2px;
}
.pending-from {
  color: var(--ink-dim);
  text-decoration: line-through;
}
.pending-arrow {
  color: var(--ink-dim);
}
.pending-to {
  font-weight: 600;
  color: var(--ink);
}

/* A queued command on a fleet card. Its own corner rather than sharing the
   "pending" word with a config change: a setting waits for the pot to apply
   it, a command waits to be heard at all and expires unsent after two hours.
   On a battery pot — reachable about five seconds in every nine hundred —
   waiting is the normal case, and a card that said nothing would let a
   reader assume the watering they asked for had happened. */


/* ------------------------------------------------------------- OTA progress
   An update is the longest thing a pot ever does at somebody's request, and
   until 2026-08-13 the cloud saw the command acked, then silence, then a
   different version some time later. The bar covers the ONE phase that has a
   percentage; everything after it is a state, and a bar parked at 100%
   through the reboot and two minutes of probation would claim a finish it
   has not earned. */
.ota-progress {
  margin-top: 12px;
  padding: 10px 12px;
  border-radius: var(--r-md, 10px);
  background: var(--tint-pending);
}
.ota-progress.confirmed {
  background: var(--tint-good);
}
.ota-progress.failed,
.ota-progress.rolled_back {
  background: var(--tint-fault);
}
/* The reason line under the bar. --ink-faint is the right weight on the page
   background and fails on these three tints — the phase this matters most on
   is `failed`, where the faint line IS the reason. --ink-dim measures 5.40 /
   5.45 / 6.11 against pending / good / fault. */
.ota-progress .t-faint {
  color: var(--ink-dim);
}
.ota-bar {
  margin-top: 8px;
  height: 5px;
  border-radius: 999px;
  background: var(--line);
  overflow: hidden;
}
.ota-bar span {
  display: block;
  height: 100%;
  border-radius: 999px;
  background: var(--leaf);
  transition: width 0.4s ease;
}


/* Queued commands and an update in flight, in the card's flow.
   These were absolutely positioned in the two bottom corners and landed on
   top of the firmware line: the card is ~299px tall and that row occupies
   255-282, which is precisely where a pill at bottom:12px sits. The top
   corners work for `sim` and `pending` because nothing else is up there and
   both are one word; these carry sentences, so they take a row of their own
   above the firmware line and the card grows by exactly as much as they
   need. */
.card-flags {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 6px;
  margin-top: 10px;
  padding: 0 12px;
}

/* --- Collapsible sections -------------------------------------------------
   The fold affordance. A flex-styled summary (card-head) erases the native
   disclosure triangle, so sections that fold carry an explicit chevron that
   turns with the state — without it the fold exists and looks like it does
   not, which reads as a missing feature. Scoped by :has so the small
   native-marker details (pack images, versions) keep their triangles. */
summary:has(> .disclose) { list-style: none; }
summary:has(> .disclose)::-webkit-details-marker { display: none; }
summary .disclose {
  display: inline-block;
  margin-right: 8px;
  transition: transform 0.15s ease;
  color: var(--ink-dim, inherit);
  font-size: 13px;
}
details[open] > summary .disclose { transform: rotate(90deg); }

/* ------------------------------------------------------------- watering band
   Where a pot's soil actually spent the window: below the line it starts
   watering at, inside the band it aims to hold, or above the target.

   One bar rather than three numbers, because the shape is the finding — a
   pot that is holding looks like one wide middle segment, and one that is
   overshooting looks top-heavy at a glance. The segments carry their own
   titles; there is no legend, because the tiles underneath name the same
   numbers. */
.band-bar {
  display: flex;
  height: 8px;
  border-radius: 999px;
  overflow: hidden;
  background: var(--line);
  margin: 12px 0 4px;
}
.band-bar > span { display: block; height: 100%; }
.band-lo { background: var(--attention, #c98a2e); }
.band-in { background: var(--forest, #24503a); }
.band-hi { background: var(--water, #4a86b8); }
/* A pot with no target gets a flat grey bar, not an empty one — nothing is
   known about it, which is different from everything being at zero. */
.band-unknown { background: var(--line); }

.dose-mix { width: 100%; border-collapse: collapse; margin-top: 8px; }
.dose-mix td { padding: 4px 8px 4px 0; vertical-align: middle; }
.dose-mix td.num { text-align: right; font-variant-numeric: tabular-nums; }

/* The watchdog and "Every pot measured" tables on Watering. A hairline
   between rows, because a row that carries a band bar and two sentences
   has no other edge and three of them run together; and the small-caps
   captions under the numbers must not wrap, because "IN / BAND" split
   across two lines reads as two captions. */
.watch-table tr + tr td { border-top: 1px solid var(--line-soft); }
.watch-table td { padding-top: 8px; padding-bottom: 8px; }
.watch-table td.num { white-space: nowrap; padding-left: 14px; }
.watch-table td:first-child { padding-right: 16px; }
/* Inside a row the bar sits between a name and a sentence, so the
   panel's 12px top margin is too much air. */
.every-table .band-bar { margin: 8px 0 6px; }

/* ------------------------------------------------------- collapsible sections
   <details> rather than a class and a click handler, so find-in-page reaches
   inside a closed one and the keyboard already works. Only the memory is
   added in JS. */
.sect > .sect-head {
  display: flex;
  align-items: center;
  gap: 10px;
  cursor: pointer;
  list-style: none;
  user-select: none;
}
.sect > .sect-head::-webkit-details-marker { display: none; }
.sect-chev {
  width: 0; height: 0;
  border-left: 5px solid var(--ink-faint);
  border-top: 4px solid transparent;
  border-bottom: 4px solid transparent;
  transition: transform .15s ease;
}
.sect[open] > .sect-head .sect-chev { transform: rotate(90deg); }
.sect-sub { margin-top: 6px; }
.sect-body { margin-top: 12px; }

/* ------------------------------------------------------------- soil chart
   Fills the section it sits in: a wide viewBox with height:auto scales
   uniformly, rather than a fixed pixel height that letterboxes the drawing
   inside a wider card. */
.soil-chart { width: 100%; }
.ser-legend {
  display: flex;
  flex-wrap: wrap;
  gap: 6px 18px;
  margin-top: 10px;
}
.ser-legend .ser {
  display: inline-flex;
  align-items: baseline;
  gap: 6px;
  font-size: 13px;
}
.ser-legend .ser i {
  width: 10px; height: 10px;
  border-radius: 3px;
  align-self: center;
}
.ser-legend .ser b { font-variant-numeric: tabular-nums; }

/* The pot pickers for the chart. Checkboxes rather than a multi-select,
   because ticking two pots to compare them should be two clicks. */
.pot-picks { display: flex; flex-wrap: wrap; gap: 8px; }
.pot-picks label {
  display: inline-flex; align-items: center; gap: 6px;
  padding: 4px 10px; border: 1px solid var(--line);
  border-radius: 999px; cursor: pointer; font-size: 13px;
}
.pot-picks label.on { border-color: var(--forest); background: var(--surface); }
.pot-picks input { margin: 0; }

/* --------------------------------------------------------- decision rows
   Each decision opens into its own timeline. A count cannot tell a steady
   state from an event, and those are opposite problems. */
.dose-row { border-bottom: 1px solid var(--line); }
.dose-row:last-child { border-bottom: 0; }
.dose-row > summary {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 8px 0;
  cursor: pointer;
  list-style: none;
}
.dose-row > summary::-webkit-details-marker { display: none; }
.dose-row > summary .num { font-variant-numeric: tabular-nums; }
.dose-row[open] > summary .sect-chev { transform: rotate(90deg); }
.dose-why { margin: 0 0 10px; }
.dose-when { margin-bottom: 14px; }
.dose-detail { margin-top: 12px; gap: 18px; }
.dose-detail [data-pick] { cursor: pointer; }
.dose-detail [data-pick]:hover td { color: var(--forest); }
