/* ClimateCloud ClimateSync Control — the look.
 *
 * Two things are borrowed and they are borrowed deliberately from different places:
 *
 *   ARRANGEMENT comes from the Smart Life phone app — a home switcher top-left, an
 *   environment strip where the weather would be, room chips, a two-column grid of device
 *   cards each with its own power button, a full-page device screen with one big dial, and
 *   four destinations on a bottom bar. It is the layout people already know from the
 *   thermostat app on their phone, which is the point.
 *
 *   COLOUR comes from ClimateSyncWeb (ClimateSyncTool/ClimateSyncWeb/public/app.css) — the
 *   same teal service accent, the same graphite chrome, the same light/dark pair, and the
 *   same HVAC mode identity colours. Smart Life's blue would have made this look like
 *   somebody else's product.
 *
 * ⚠ COLOUR IS NEVER THE ONLY SIGNAL. Every state shown by colour also carries a word or a
 * glyph — ON, OFF, FLOOR LIMIT, ERR 0007, SENDING…, STALE. A client requirement, and the
 * reason the mode colours are used as accents rather than as the meaning itself.
 */

/* ── tokens ─────────────────────────────────────────────────────────────────── */

:root {
  /* Surfaces — ClimateSyncWeb, light */
  --bg:          #f2f5f8;
  /* ⚠ `--bg-2` WAS DELETED 1 Sep 2026 — nothing read it, in either theme. The note below
     names it as the token carrying the unit heading strip; that was wrong. `.unit-head` is
     painted with `color-mix(in srgb, var(--bg) 92%, transparent)`, and the surface that
     happens to share #e9eef4 is `--panel-2`, which is the one actually in use. The contrast
     figures in that note still stand, because they were taken by compositing each element's
     REAL backdrop in the running page rather than by trusting the token name — which is the
     only reason a wrong name did not become a wrong colour. Re-confirmed 1 Sep 2026: zero
     contrast failures across 30 screens in both themes. */
  --panel:       #ffffff;
  --panel-2:     #e9eef4;
  --ink:         #16202c;
  --ink-2:       #4a5a6e;
  /* ⚠ #5e6b7f. Measured 26 Aug 2026 at 375px: #7787a0 gave 3.33:1 against the page background
     and 3.65:1 on a white card, where AA asks 4.5:1 for normal text. It is the muted token, so
     that was not one label — it was every timestamp, every sub-line, every MAC, and the outcome
     words Confirmed / Sent / Failed, which are the least legible text in an app whose whole
     promise is those three words.
     ⚠ AND THERE ARE THREE SURFACES, NOT TWO. That fix was measured against the page and a white
     card and shipped at 4.54 / 4.97 — but a third surface (#e9eef4) carries the unit heading
     strip, and
     on that surface the same value was 4.25 and had never been asked. Re-measured in the running
     page on 27 Aug 2026 by compositing each element's real backdrop rather than assuming which
     token was behind it. Now 4.83 on the page, 5.29 on a card, 4.63 on the strip.
     ⚠ THE TARGET IS 4.6, NOT 4.5. A value that lands exactly on the threshold has no margin for
     the browser's own rounding, and the previous pass shipped two tokens sitting on 4.50. */
  --ink-3:       #5e6b7f;
  --rule:        #d7dfe9;
  --rule-soft:   #e7edf3;

  /* ⚠ #0b7f96 IS ClimateSyncWeb'S OWN ACCENT AND IT STAYS EXACTLY THAT. Colour comes from
     ClimateSyncWeb by design and a test pins this hex, so darkening it for legibility would
     have quietly moved the brand to fix a text problem. It is a FILL here — buttons, borders,
     the selected chip — and white on it reads 4.68:1, which is fine.
     ⚠ AS SMALL TEXT IT IS NOT FINE: 4.27 on the page and 3.81 on its own tint, carrying the
     word "Scheduled". So the two jobs are two tokens now. --accent identifies; --accent-text is
     the same hue darkened until it clears 4.6 on every surface it is read on. Anywhere this
     colour is a `color:` and not a `background:`, use --accent-text. */
  --accent:      #0b7f96;
  --accent-text: #0a7185;
  /* ⚠ THE INK THAT GOES ON A SATURATED FILL, WHICHEVER FILL IT IS. The name says accent
     because .btn.primary was the first to need it, and four other filled controls went on
     hard-coding #fff instead — which is right in this theme and wrong in the other one,
     where every fill is lightened for legibility as text. Measured 27 Aug 2026, white on
     the dark theme's fills: --warn and --heat 2.37, --accent 2.78, --dry 2.89, --cool
     2.91, --crit and --haux 3.16, --auto 3.19, --fan 3.85. Not one of the ten reached
     4.5, and the two carrying words were the Delete button and the zone's own ON button.
     This token reads 4.3 to 6.98 on all ten. */
  --accent-ink:  #ffffff;
  --accent-soft: #d6ecf1;

  /* ⚠ THE STATUS INKS ARE READ ON FOUR SURFACES AND WERE TUNED FOR NONE OF THEM. Each of these
     is painted on the page, on a white card, on the #e9eef4 heading strip AND on its own soft
     tint, and until 27 Aug 2026 the tints had never been measured at all. Swept in the running
     page across every screen, compositing each element's real backdrop:
         --warn on --warn-soft   3.20  ("On hold", and the 17px hold heading)
         --accent on --accent-soft 3.81  ("Scheduled")
         --ok on --ok-soft       4.37  ("Confirmed", "Calling")
         --crit on --crit-soft   4.50  ("Failed", "Equipment fault")
     Amber was the worst and carries the most consequential word on the screen — a unit somebody
     is physically working on. Each token is the SAME HUE darkened until it clears 4.6 on the
     worst of its four surfaces; the tints are untouched, because for three of the four the page
     background was the binding constraint and lightening a tint could not have fixed them.
     ⚠ DARK THEME IS SEPARATE AND WAS ALREADY CLEAN — the same sweep found nothing there, so
     nothing below the light block changes. */
  --ok:          #2a764c;
  --warn:        #8e6206;
  --crit:        #ad422e;
  --ok-soft:     #dcefe4;
  --warn-soft:   #f7ecd2;
  --crit-soft:   #f6e0da;

  /* HVAC mode identity — carried from the ClimateSync desktop tool's button colours.
     ⚠ THESE ARE NOT THE STATUS INKS, THEY ONLY USED TO MATCH. --heat and --warn were both
     #b07908 and --haux and --crit were both #b0432f, which made them look like duplicates worth
     tidying into one. They are not: a status ink has to be READ as small text on four surfaces,
     and a mode colour identifies a mode on a dial and a tile. The status inks were darkened for
     legibility on 27 Aug 2026 and these deliberately were NOT, because they are the desktop
     tool's colours and matching it is the point. Do not re-unify them. */
  --cool:        #3a6f9c;
  --heat:        #b07908;
  --haux:        #b0432f;
  --hh:          #8d2f22;
  --auto:        #6b4bab;
  --fan:         #7787a0;
  --dry:         #2c7a8f;
  --off:         #7787a0;

  /* Graphite chrome — the console surface, dark in both themes.
     ⚠ ONE SURFACE, NOT A SET. `--console-line` (a rule colour) and `--console-dim` (a muted
     ink) were deleted 1 Sep 2026: nothing read either, in either theme. They belonged to a
     richer console panel with dividers and secondary text that the app no longer has — the
     only thing still built on this surface is `.toast`, which needs a background and an ink
     and nothing else. Left as a tombstone rather than a gap: the next person to want a
     divider on a dark surface should decide that deliberately, not find half a palette
     already sitting here and assume it was meant. */
  --console:      #161b22;
  --console-ink:  #c9d4e2;

  --mono: ui-monospace, "Cascadia Mono", Consolas, "SFMono-Regular", Menlo, monospace;
  --sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;

  /* Smart Life geometry: generous radii, soft shadows, lots of air */
  --r-card:  18px;
  --r-tile:  20px;
  --r-field: 12px;
  --r-pill:  999px;

  --shadow-card: 0 1px 2px rgb(22 32 44 / .05), 0 6px 20px rgb(22 32 44 / .06);
  --shadow-lift: 0 2px 6px rgb(22 32 44 / .08), 0 14px 34px rgb(22 32 44 / .12);
  --shadow-bar:  0 -1px 0 var(--rule), 0 -12px 28px rgb(22 32 44 / .05);

  --gap: 12px;
  --pad: 16px;
  --tabs-h: 58px;
  --top-h: 60px;
  /* The top bar's measured height, published by app.js. `--top-h` is only its minimum —
     padding, the safe-area inset and the A−/A+ text size all change the real one. */
  --top-real: 72px;

  /* Safe areas, resolved once so every rule can use them without repeating the fallback */
  --sa-top:    env(safe-area-inset-top, 0px);
  --sa-bottom: env(safe-area-inset-bottom, 0px);
  --sa-left:   env(safe-area-inset-left, 0px);
  --sa-right:  env(safe-area-inset-right, 0px);
}

:root[data-theme="dark"] {
  --bg:        #0d1117;
  /* `--bg-2` deleted here too — see the light block. */
  --panel:     #151b23;
  --panel-2:   #1c232d;
  --ink:       #dde5ef;
  --ink-2:     #a2b0c2;
  /* Dark measured 4.11:1 on #0d1117 — under the same 4.5 bar, lightened to clear it. */
  --ink-3:     #8b9ab2;
  --rule:      #29323e;
  --rule-soft: #212934;

  --accent:      #3aa8c0;
  /* ⚠ THE SAME VALUE ON PURPOSE. The light theme splits identity from readable text because its
     accent failed as small text; this one measured clean on every surface in the same sweep, so
     splitting it would be inventing a difference. The token exists in both themes because the
     rules that use it do — a `color:` that resolved to nothing in dark would be invisible. */
  --accent-text: #3aa8c0;
  --accent-ink:  #06222a;
  --accent-soft: #14313a;

  --ok:        #58a97a;
  --warn:      #d0a144;
  --crit:      #d4785f;
  --ok-soft:   #16301f;
  --warn-soft: #33280f;
  --crit-soft: #331a14;

  --cool:      #6d9cc6;
  --heat:      #d0a144;
  --haux:      #d4785f;
  --hh:        #c96a58;
  --auto:      #9c82d6;
  --fan:       #74839a;
  --dry:       #4fa3b8;
  --off:       #74839a;

  --shadow-card: 0 1px 2px rgb(0 0 0 / .4), 0 6px 20px rgb(0 0 0 / .3);
  --shadow-lift: 0 2px 8px rgb(0 0 0 / .5), 0 16px 40px rgb(0 0 0 / .45);
  --shadow-bar:  0 -1px 0 var(--rule), 0 -12px 28px rgb(0 0 0 / .3);
}

/* ── base ───────────────────────────────────────────────────────────────────── */

* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html { font-size: 17px; }            /* the A−/A+ control moves this */
html, body { height: 100%; margin: 0; }

body {
  background: var(--bg);
  color: var(--ink);
  font-family: var(--sans);
  font-size: 1rem;
  line-height: 1.45;
  -webkit-font-smoothing: antialiased;
  overscroll-behavior-y: none;
}

h1, h2, h3, h4 { margin: 0; font-weight: 650; letter-spacing: -.01em; }
button, input, select, textarea { font: inherit; color: inherit; }
button { cursor: pointer; }
[hidden] { display: none !important; }
.muted { color: var(--ink-3); }
.small { font-size: .82rem; }
/* `.grow` was here — a `flex: 1` utility nothing has used since the layouts that wanted it
   were rewritten. Deleted 1 Sep 2026 with `.chips`, `.gate-mark` and `.via-site`. */
.mono { font-family: var(--mono); }

/* ⚠ FOR THE ONE HEADING PER SCREEN THAT IS NOT DRAWN. Every screen shows its title in
   the header bar, where it is either the scope switcher (a button) or a button styled
   as a title and made inert — neither of which a screen reader can offer as a heading.
   So the level-1 heading is placed at the top of the view instead, and hidden the way
   that keeps it in the accessibility tree: display:none and visibility:hidden would
   remove it from that tree as well, which is the whole thing being asked for here. */
.sr-only {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px; padding: 0; border: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

:where(button, a, input, [tabindex]):focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: 6px;
}

/* ── shell ──────────────────────────────────────────────────────────────────── */

.app {
  min-height: 100%;
  display: flex;
  flex-direction: column;
  padding-bottom: calc(var(--tabs-h) + var(--sa-bottom));
}

.top {
  position: sticky;
  top: 0;
  z-index: 20;
  display: flex;
  align-items: center;
  gap: 10px;
  min-height: var(--top-h);
  padding: calc(var(--sa-top) + 10px) calc(var(--pad) + var(--sa-right)) 10px
           calc(var(--pad) + var(--sa-left));
  background: color-mix(in srgb, var(--bg) 86%, transparent);
  backdrop-filter: blur(14px);
  -webkit-backdrop-filter: blur(14px);
}

/* The home switcher. Smart Life puts the estate you are looking at in the biggest type on
   the screen, because on a phone "which building am I about to change" is the question. */
.home-switch {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 1px;
  flex: 1;
  min-width: 0;
  padding: 4px 8px 4px 0;
  background: none;
  border: 0;
  text-align: left;
}
.home-name {
  display: flex;
  align-items: center;
  gap: 5px;
  max-width: 100%;
  min-width: 0;
  font-size: 1.28rem;
  font-weight: 700;
  letter-spacing: -.02em;
}
/* ⚠ THE CLIPPING BELONGS TO THE TEXT, NOT TO THE ROW — for two reasons, and it was wrong
   for both. This rule used to sit on .home-name itself.

   1. The chevron below is an 8x8 box turned 45 degrees, so its BOUNDING box is 11.31px and
      each corner sticks 1.66px outside the layout box the browser reserves for it. With
      `overflow: hidden` on the row, that tip was cut off — measured on "Favourites",
      scrollWidth 118 against clientWidth 116, with 590px of empty header to its right.
   2. `text-overflow: ellipsis` DOES NOTHING on a flex container. The text becomes an
      anonymous flex item and there is no block box for the ellipsis to belong to, so a long
      site name was hard-cut mid-letter instead: measured at 475px of text in a 192px box,
      overflowing by 283px with no "…" anywhere. On a phone that is every long name.

   Moving both onto the text span fixes each: the span is a block-level flex item, so the
   ellipsis has something to attach to, and the chevron is now a sibling of the clipped box
   rather than inside it. */
.home-name-text {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.home-name::after {
  content: "";
  flex: 0 0 auto;
  width: 8px; height: 8px;
  margin-top: 2px;
  border-right: 2px solid var(--ink-2);
  border-bottom: 2px solid var(--ink-2);
  transform: rotate(45deg);
}
.home-sub {
  font-size: .76rem;
  color: var(--ink-3);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  max-width: 100%;
}

/* ⚠ margin-left:auto, NOT "the thing before it happens to be flexible". The actions were
   pushed right only as a side effect of `.home-switch { flex: 1 }` eating the free space -
   so on every screen with a back button, where the home switcher is `hidden`, they
   collapsed leftward and sat against the back arrow. Reported on a zone screen 21 Aug 2026,
   and it was every detail screen: zone, unit, Command Activity, each Settings sub-page.
   `auto` pins them to the right edge whatever is or is not beside them; where the home
   switcher IS present it has already taken the slack, so this changes nothing there. */
.top-acts { display: flex; gap: 6px; flex: 0 0 auto; margin-left: auto; }

/* The header pair — Alerts and Refresh. A painted disc with a shadow, so the hit area is
   extended rather than the disc: 40 + 2 either side = 44.
   ⚠ AND THE 2 EITHER SIDE HAS TO EXIST. `.top-acts` sets `gap: 6px`, so each of these has
   3px of its own to spend and the extension lands in it. `.unit-pwr` used to be the example
   named here and is no longer: it had 2px on one side and none on the other, so half of its
   extension reached into the control beside it. Measure the gap before reusing this. */
.icon-btn {
  position: relative;
  width: 40px; height: 40px;
  display: grid;
  place-items: center;
  border: 0;
  border-radius: var(--r-pill);
  background: var(--panel);
  box-shadow: var(--shadow-card);
  color: var(--ink-2);
}
.icon-btn::after { content: ""; position: absolute; inset: -2px; border-radius: inherit; }
.icon-btn:active { transform: scale(.94); }
.icon-btn svg { width: 20px; height: 20px; }
.icon-btn.spinning svg { animation: spin 900ms linear infinite; }

/* ⚠ 40 + 2 EITHER SIDE = 44 — THE TRICK `.icon-btn` USES, TWO ELEMENTS TO ITS RIGHT.
   This is the way back out of every screen that has one: zone, unit, group, schedule, Names,
   Sites, Users and each Options sub-page. It answered to 40x41 on thirteen screens measured
   1 Sep 2026, while the Alerts and Refresh discs beside it — same size, same shape, same
   header — had been extended to 44 already. The earlier fix was named for `.icon-btn`, "the
   header pair", rather than for "a 40px control in this header", and the back arrow is not
   in that pair. Its nearest neighbour is 10px away, so 2px cannot reach anything. */
.top-back {
  /* One number for the chevron, because the centring below is derived from it. */
  --chev: 9px;
  /* For the extender below. It anchors nothing else: the chevron is a grid item, not an
     absolutely positioned one, so the layout is unchanged by this. */
  position: relative;
  width: 40px; height: 40px;
  flex: 0 0 auto;
  display: grid;
  place-items: center;
  border: 0;
  border-radius: var(--r-pill);
  background: var(--panel);
  box-shadow: var(--shadow-card);
  color: var(--ink);
}
/* The arrow is a square with two of its four borders drawn, turned through 45 degrees.
   ⚠ CENTRING THE SQUARE DOES NOT CENTRE THE ARROW. Only the left and bottom borders are
   inked, so the glyph is two arms meeting at the bottom-left corner; turned, that corner
   swings out to the left by half the square's diagonal while both arm ends land exactly on
   the vertical centre line. Every drawn pixel is therefore in the LEFT HALF of the rotated
   bounding box and none of it to the right - so a square sitting dead centre draws an arrow
   shoved against the left edge of the circle, which is what this looked like.
   The correction is half the ink's own width: (size x square root of 2) / 4 = size x 0.3536.
   ⚠ THE TRANSLATE IS WRITTEN FIRST SO IT ACTS IN THE BUTTON'S FRAME. Transform functions
   apply right to left; after the rotate it would travel along the turned axis and push the
   arrow diagonally instead of across.
   ⚠ A HAND-TUNED NEGATIVE LEFT MARGIN USED TO DO THIS JOB and made the fit worse, not
   better: on a grid-centred item a negative margin is shared with the centring, so half of
   it comes straight back, and it was pushing the arrow further left besides. (Described
   rather than quoted, so searching for the old declaration finds nothing here.) */
.top-back::before {
  content: "";
  width: var(--chev); height: var(--chev);
  border-left: 2.2px solid currentColor;
  border-bottom: 2.2px solid currentColor;
  transform: translateX(calc(var(--chev) * 0.3536)) rotate(45deg);
}
/* The 2px of reach, invisible and on top of nothing — see the note above the rule. */
.top-back::after {
  content: "";
  position: absolute;
  inset: -2px;
  border-radius: inherit;
}
.top-back:active { transform: scale(.94); }

.view {
  flex: 1;
  padding: 4px calc(var(--pad) + var(--sa-right)) 24px calc(var(--pad) + var(--sa-left));
}
.view:focus { outline: none; }

/* Bottom bar. Four destinations; the active one is teal and says so in text as well. */
.tabs {
  position: fixed;
  left: 0; right: 0; bottom: 0;
  z-index: 30;
  display: flex;
  padding-bottom: var(--sa-bottom);
  background: color-mix(in srgb, var(--panel) 92%, transparent);
  backdrop-filter: blur(18px);
  -webkit-backdrop-filter: blur(18px);
  box-shadow: var(--shadow-bar);
}
.tabs button {
  flex: 1;
  min-height: var(--tabs-h);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 3px;
  border: 0;
  background: none;
  color: var(--ink-3);
  padding: 6px 2px;
}
.tabs button.on { color: var(--accent-text); }
.tabs .tab-ico { display: block; height: 23px; }
.tabs .tab-ico svg { width: 23px; height: 23px; }
.tabs .tab-lbl { font-size: .66rem; font-weight: 600; letter-spacing: .01em; }
.tabs button:active .tab-ico { transform: scale(.9); }

/* ── the sign-in gate ────────────────────────────────────────────────
   Signed out, the only screen is the sign-in screen — so the furniture for navigating a fleet
   nobody can see yet goes with it.

   ⚠ THE BOTTOM PADDING HAS TO GO TOO. It is sized from --tabs-h on both .app and .view, and
   leaving it would put 58px of nothing under a short screen with no tab bar beneath it. */

:root.gate .tabs,
:root.gate .top-acts { display: none; }
:root.gate .app { padding-bottom: var(--sa-bottom); }
:root.gate .view { padding-bottom: 24px; }

/* The header keeps the product's name and stops offering to switch into somewhere. */
:root.gate .home-switch { pointer-events: none; }
:root.gate .home-sub { display: none; }

/* ── connection bar ─────────────────────────────────────────────────────────── */

.netbar {
  position: sticky;
  top: var(--top-real);
  z-index: 19;
  margin: 0 calc(var(--pad) + var(--sa-left)) 8px;
  padding: 9px 13px;
  border-radius: var(--r-field);
  font-size: .8rem;
  font-weight: 600;
  display: flex;
  align-items: center;
  gap: 8px;
}
.netbar.bad  { background: var(--crit-soft); color: var(--crit); }
.netbar.warn { background: var(--warn-soft); color: var(--warn); }
.netbar.ok   { background: var(--ok-soft);   color: var(--ok); }
.netbar svg { width: 16px; height: 16px; flex: 0 0 auto; }

/* ── alerts ─────────────────────────────────────────────────────────────────────
   The connection states used to be a full-width strip. They now live behind the bell in the
   header: one dot, coloured by the worst thing still unacknowledged. */

.alert-dot {
  position: absolute;
  top: 6px; right: 6px;
  width: 10px; height: 10px;
  border-radius: 50%;
  border: 2px solid var(--panel);
  background: var(--ok);
}
.alert-dot.ok   { background: var(--ok); }
.alert-dot.warn { background: var(--warn); }
/* ⚠ Only the critical one moves. Behind an icon a lost connection is easy to walk past, and
   that is the one state where walking past it means trusting readings that stopped. */
.alert-dot.bad  { background: var(--crit); animation: alert-pulse 1.8s ease-in-out infinite; }
@keyframes alert-pulse {
  0%, 100% { transform: scale(1);   opacity: 1; }
  50%      { transform: scale(1.3); opacity: .72; }
}

.alert-list { display: grid; gap: 10px; }

/* The severity shows in the bar and the icon, never in the row's background: `--ok-soft` is
   a near-black in the dark theme, and body text on top of it stops being legible. */
.alert-row {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 4px 10px;
  padding: 12px 13px;
  border-radius: var(--r-field);
  background: var(--panel-2);
  border-left: 3px solid transparent;
}
.alert-row.ok   { border-left-color: var(--ok); }
.alert-row.warn { border-left-color: var(--warn); }
.alert-row.bad  { border-left-color: var(--crit); }
.alert-row.ok   .alert-ico { color: var(--ok); }
.alert-row.warn .alert-ico { color: var(--warn); }
.alert-row.bad  .alert-ico { color: var(--crit); }
/* Acknowledged is dimmed, not deleted — the condition has not gone away. */
.alert-row.acked { opacity: .5; }

.alert-ico { flex: 0 0 auto; line-height: 0; padding-top: 1px; }
.alert-txt { display: grid; gap: 3px; min-width: 0; }
.alert-txt b { font-size: .88rem; }
.alert-txt span { font-size: .8rem; color: var(--ink-2); line-height: 1.45; }
.alert-acts {
  grid-column: 2;
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin-top: 6px;
}
.alert-ack {
  /* ⚠ 44px, for the same reason as `.btn.sm` below - see the note there. This one is the
     Acknowledge on an alert row, and it was missed by the first pass because it is its own
     class rather than a variant of `.btn`. */
  min-height: 44px;
  padding: 0 13px;
  border: 1px solid var(--rule);
  border-radius: var(--r-pill);
  background: transparent;
  color: var(--ink-2);
  font-size: .78rem;
  font-weight: 650;
}
.alert-ack:active { transform: scale(.96); }
/* ⚠ 44px, NOT 32. Used in two places - the Remove button on a person and the action
   button on an alert. The second is the one that matters: after the reads close, the
   "Sign in" on the refused-fleet alert is the ONLY way out of an app showing nothing,
   and it was a 32px target. Measured in the running page on 27 Aug 2026, both themes.

   ⚠ THE FIRST VERSION OF THIS COMMENT CALLED IT "the only control class still under the
   minimum", which was wrong within four hundred lines: `.alert-ack` is 32px too, and sits
   on the same sheet as the button this paragraph is about. Naming a class and then listing
   the cases I happened to know about is the same fault as a guard whose name claims more
   than its body checks. Both are raised; the sweep that found them measures every control
   on the page rather than the ones somebody thought of.

   The height is raised rather than the hit area extended with padding and a negative
   margin: that trick made a control in 1.1.65 report 44 and still answer to 37, because
   the extended region sat under its neighbour. A taller button cannot do that. */
.btn.sm { min-height: 44px; padding: 0 13px; font-size: .8rem; }

/* ── the environment strip (where Smart Life shows the weather) ──────────────── */

.env {
  display: flex;
  align-items: stretch;
  gap: 4px;
  margin: 4px 0 var(--gap);
  padding: 13px 6px;
  border-radius: var(--r-card);
  background: var(--panel);
  box-shadow: var(--shadow-card);
}
.env-cell {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 1px;
  padding: 0 6px;
  text-align: center;
}
.env-cell + .env-cell { border-left: 1px solid var(--rule-soft); }
.env-v {
  font-size: 1.12rem;
  font-weight: 700;
  letter-spacing: -.02em;
  white-space: nowrap;
}
.env-v.dim { color: var(--ink-3); font-weight: 600; }
.env-k {
  font-size: .64rem;
  font-weight: 600;
  letter-spacing: .06em;
  text-transform: uppercase;
  color: var(--ink-3);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  max-width: 100%;
}
.env-v .u { font-size: .7em; font-weight: 600; color: var(--ink-3); }
/* ⚠ FOUR LONG LABELS DO NOT FIT A PHONE ON ONE LINE, AND CLIPPING THEM DESTROYS THEM.
   `.env-k` ellipsises, which is right for Home's short words (Avg room, Calling, Updated).
   Command Activity's are CONFIRMED / UNCONFIRMED / FAILED / SCHEDULED, and at 375px the
   first two rendered as "CONFIRM…" and "UNCONFI…" — on that screen the label IS the
   meaning, so it wraps instead. Only rows that opt in are affected. */
.env-tight .env-k {
  white-space: normal;
  overflow: visible;
  letter-spacing: .03em;
  line-height: 1.15;
}

/* The filter block: the state chips on one line, the controls that are not tabs on the next.
   Measured 21 Aug 2026 - with a chip per ClimateSync this row was 1985px wide in a 343px
   window on the All tab, three chips visible and thirteen off the side. */
/* ⚠ THE FILTERS AND THE ALL ON / ALL OFF BAR WERE TOUCHING AT EXACTLY 0px. `.chip-tools`
   carried no bottom margin and `.allbar` no top one, so two rows that do completely different
   jobs - one narrows what you are LOOKING at, the other switches real heating - sat flush
   against each other and read as one control block. The gap goes on `.chip-block` rather than
   on `.allbar`, which is shared by six other places, three of them with their own margins.
   Rhythm down this stack is now 12 / 14 / 18: even above, a little more before the bar that
   commands equipment, most before the list heading. */
.chip-tools {
  display: flex;
  align-items: center;
  gap: 8px;
  margin: 8px 2px 0;
  flex-wrap: wrap;
}
.chip-block { margin-bottom: 14px; }

/* ⚠ TWO HEADINGS STACKED READ AS ONE DOUBLE-DECKER HEADING. "Ungrouped" used to carry an
   INLINE margin-top of 6px, which is right when it follows a grid of tiles - they bring their
   own space - and far too tight when the scope holds no groups and it lands directly under
   the "Zones" heading, 9px below it, the pair reading as one control with a stray number.
   Inline styles beat any selector, so the tightening is expressed the other way round: the
   generous heading margin is the default, and only a heading that directly follows a tile
   grid pulls back up. */
.tiles + .sec-head { margin-top: 6px; }

/* A row in the site/group picker: the body chooses it, the star pins it to the switcher.
   Two targets in one row, so the row itself is not a button - the body is.
   ⚠ min-height 44 SO THE ROW CAN CONTAIN ITS OWN CONTROLS. These rows were 41px tall while
   carrying a 44px button, which is how `.eye-btn` came to hang over the rows either side of
   it and lose three pixels at each end to a neighbour. A row shorter than the thing inside it
   does not clip — it collides. */
.pick-row { display: flex; align-items: center; gap: 10px; min-height: 44px; }
/* ⚠ `align-self: stretch`, AND IT IS A TAP TARGET FIX THAT MOVES NOTHING. The row is a
   centring flex line, so this button was only ever as tall as its own text: with a subtitle
   that wraps it measured 60px, and with one that fits on a single line it measured 41 — the
   row itself staying 66px either way. So the target SHRANK as the phone got WIDER, because a
   wider line stops the subtitle wrapping: measured 1 Sep 2026 on the Names screen, 3 of 33
   rows were under 44px at 375px and 18 of 33 at 414px. Reading the CSS would never find that.
   The row's content box is exactly 44px (min-height 54 less 11px padding a side), so
   stretching fills it precisely — measured before and after, every row height on the screen
   is unchanged and only the button grows, 41 -> 44.
   ⚠ THE NEIGHBOUR WAS ALREADY RIGHT, WHICH IS THE TELL. `.eye-btn` beside it has been 44x44
   all along; when one control on a row is correct and the one next to it is not, it is the
   odd one out that people aim for. */
.pick-main {
  flex: 1;
  min-width: 0;
  align-self: stretch;
  border: 0;
  background: none;
  color: inherit;
  text-align: left;
  padding: 0;
}
/* ⚠ THE SAME 44/34 AS .eye-btn, AND FOR THE SAME REASON — it is the same kind of control on
   the same kind of row, and it had no hit extension at all: a flat 34x34 sitting beside a row
   body that opens something else. Drawn by ::before so the target is the box. */
.pin-btn {
  flex: 0 0 auto;
  position: relative;
  width: 44px; height: 44px;
  box-sizing: border-box;
  display: grid;
  place-items: center;
  border: 0;
  background: none;
  color: var(--ink-3);
}
.pin-btn::before {
  content: "";
  position: absolute;
  inset: 5px;
  border: 1px solid var(--rule);
  border-radius: var(--r-pill);
  background: var(--panel);
}
.pin-btn svg { position: relative; }
.pin-btn.on { color: var(--accent-text); }
.pin-btn.on::before { border-color: var(--accent-text); background: var(--accent-soft); }
.pin-btn:active { transform: scale(.9); }
/* ⚠ 44, AND IT LINES UP WITH THE SEARCH FIELD ABOVE IT — that is the design, not a bolt-on.
   These pills were 34px tall directly under a 44px search box and directly above two 46px
   buttons, so the toolbar had three different heights stacked on top of each other and the
   smallest one was the only part you had to aim at. Raising them to 44 makes the whole column
   one rhythm AND clears the thumb target in the same change. The horizontal padding grows with
   the height because a 44px pill at 10px padding reads as stubby. */
.chip-select {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  min-height: 44px;
  padding: 0 14px;
  border: 1px solid var(--rule);
  border-radius: var(--r-pill);
  background: var(--panel);
  color: var(--ink-2);
  font-size: .82rem;
  font-weight: 600;
  max-width: 62%;
}
.chip-select span {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* The last glyph is the disclosure chevron; point it down so the button reads as a menu. */
.chip-select svg:last-child { transform: rotate(90deg); opacity: .7; flex: 0 0 auto; }
.chip-select svg:first-child { flex: 0 0 auto; opacity: .8; }
/* The count rides inside the button, so the answer and its size are one glance. */
.sel-count {
  font-variant-numeric: tabular-nums;
  font-size: .78rem;
  opacity: .75;
}
.chip-select.on {
  border-color: var(--accent-text);
  color: var(--accent-text);
  background: var(--accent-soft);
}

/* ── chips ──────────────────────────────────────────────────────────────────────
   ⚠ `.chips` — THE SCROLLING ROW — IS GONE, AND ITS RULES WENT WITH IT ON 1 Sep 2026.
   Groups stopped being filter chips; what remains is `.chip-tools`, two chips that do not
   scroll. Forty lines described a swipe row, a `pointer: fine` scrollbar and an end-fade
   driven by `.more-left` / `.more-right` — classes app.js has never set, on a container the
   app has not rendered since the day groups moved out. Written down because a fade mask that
   never appears looks like a bug worth wiring up, and it is not: it is a rule for a design
   that was replaced. `.chip` and `.chip-select` below are live and unaffected. */

/* ⚠ 44 TO MATCH .chip-select, WHICH SITS BESIDE IT IN THE SAME ROW. Two pills of different
   heights side by side is the one thing worse than two pills that are both too small. */
.chip {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  gap: 6px;
  min-height: 44px;
  padding: 0 16px;
  border: 1px solid transparent;
  border-radius: var(--r-pill);
  background: var(--panel);
  color: var(--ink-2);
  font-size: .84rem;
  font-weight: 600;
  box-shadow: var(--shadow-card);
  scroll-snap-align: start;
  white-space: nowrap;
}
.chip.on {
  background: var(--accent);
  border-color: var(--accent-text);
  color: var(--accent-ink);
}
.chip .count {
  font-size: .74rem;
  font-weight: 700;
  opacity: .68;
}
.chip:active { transform: scale(.96); }

/* ── section heads ──────────────────────────────────────────────────────────── */

.sec-head {
  display: flex;
  align-items: center;
  gap: 8px;
  margin: 18px 2px 9px;
  font-size: .96rem;
  font-weight: 700;
  letter-spacing: -.01em;
}
.sec-head .count {
  font-size: .74rem;
  font-weight: 600;
  color: var(--ink-3);
}
/* ⚠ A HEADING'S ACTION IS TEXT, AND TEXT CANNOT BE GROWN TO 44 WITHOUT WRECKING THE HEADING.
   This was the smallest control in the app at 61x28. Making it visually 44 tall would add
   16px to every section heading on every screen — the type would still be 0.8rem, just
   floating in a band, and the page would gain a centimetre of nothing per screen.
   So the PAINTED size is unchanged and the HIT AREA is extended with a pseudo-element, which
   is the technique .icon-btn, .tile-pwr and .group-pwr already use here.
   ⚠ -4px HORIZONTALLY, NOT -8px, AND THAT IS DELIBERATE. Two of these can sit side by side
   (`.link + .link` is 12px apart), and a hit area generous enough to overlap its neighbour
   turns a small target into a wrong one — see the control that measured 44 and answered to
   37. 4px a side leaves 4px of clear space between them. */
.sec-head .link {
  position: relative;
  border: 0;
  background: none;
  color: var(--accent-text);
  font-size: .8rem;
  font-weight: 650;
  padding: 4px 2px;
  display: inline-flex;
  align-items: center;
  gap: 3px;
  white-space: nowrap;
}
.sec-head .link::after {
  content: "";
  position: absolute;
  inset: -8px -4px;          /* 28 + 16 = 44 tall, and still clear of a sibling link */
}
/* Only the first action is pushed right; any after it sit beside it. */
.sec-head .link:first-of-type { margin-left: auto; }
.sec-head .link + .link { margin-left: 12px; }
.sec-head .link svg { width: 14px; height: 14px; }

/* ── the zone tile (Smart Life's device card) ───────────────────────────────── */

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

.tile {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: 13px 13px 12px;
  border: 1px solid transparent;
  border-radius: var(--r-tile);
  background: var(--panel);
  box-shadow: var(--shadow-card);
  text-align: left;
  color: inherit;
  transition: transform .12s ease, box-shadow .12s ease;
}
.tile:active { transform: scale(.975); }
.tile.on { border-color: color-mix(in srgb, var(--mode, var(--accent)) 34%, transparent); }
.tile.gone { opacity: .72; }

/* The icon plate. Tinted by mode when the zone is on, neutral when it is not — the same
   trick Smart Life uses to make a room readable at a glance from across the kitchen. */
.tile-ico {
  width: 40px; height: 40px;
  display: grid;
  place-items: center;
  border-radius: 13px;
  background: var(--panel-2);
  color: var(--ink-3);
  margin-bottom: 7px;
}
.tile.on .tile-ico {
  background: color-mix(in srgb, var(--mode, var(--accent)) 15%, var(--panel));
  color: var(--mode, var(--accent));
}
.tile-ico svg { width: 22px; height: 22px; }

/* ⚠ THE 44px THAT USED TO BE HERE WAS CLEARING A BUTTON ON THE ROW ABOVE.
   `.tile-name` and `.tile-sub` both reserved 44px "clear of the power button". Measured in
   the page at 375px on 1 Sep 2026, in tile coordinates: `.tile-pwr` is absolutely positioned
   at top:11 and is 40px tall, so it occupies y 11–51 — beside `.tile-ico`, which is the row
   ABOVE. The name starts at y 60. The button's bottom edge is eleven pixels clear of the
   name's top, at every width, because both numbers are fixed and neither depends on the card.
   The cost was 44 of the name's 138px — a third of the line — spent on nothing. A zone may be
   named with 20 characters and the card was showing nine to fifteen: "Meeting Room" rendered
   as "Meeting R…". `.tile-unit`, immediately below and never given the padding, has been
   using the full width all along, which is what proves the width is free.
   ⚠ IF A CONTROL IS EVER MOVED DOWN BESIDE EITHER OF THESE, THE PADDING COMES BACK — but
   measured against where that control actually is, not against the corner it started in. */
/* ⚠ TWO LINES, BECAUSE THE DEVICE ALLOWS TWENTY CHARACTERS AND ONE LINE HOLDS FIFTEEN.
   Reclaiming the phantom 44px above took a 2-up card's name box from 94px to 138 and fixed
   the common case — "Meeting Room" was rendering as "Meeting R…". It did not close the gap:
   measured at 375px, 138px holds 15 typical characters, 17 narrow ones and 9 wide ones,
   against the 20 a zone name may actually be. A name somebody is allowed to type should be a
   name they can read on the card, so the headline gets a second line when it needs one.
   ⚠ THE HEIGHT IS RESERVED WHETHER OR NOT IT IS USED. Cards sit in a grid row that stretches
   to its tallest member, so letting only the long ones wrap leaves one card's temperature
   sitting lower than its neighbour's — the row is the same height and the contents no longer
   line up. A fixed two-line box costs 15px a card (230 -> 245) and keeps every card identical.
   ⚠ AND IT IS CLAMPED AT TWO. Without the clamp a pathological name would push the readings
   off the bottom of the card; with it, the twentieth character is the last one that can
   matter and nothing below it moves. */
.tile-name {
  font-size: .92rem;
  font-weight: 650;
  letter-spacing: -.01em;
  overflow: hidden;
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  line-height: 1.2;
  min-height: 2.4em;        /* 2 lines at the line-height above */
}
.tile-sub {
  font-size: .74rem;
  color: var(--ink-3);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.tile-temp {
  display: flex;
  align-items: baseline;
  gap: 2px;
  margin-top: 7px;
}
.tile-temp .v {
  font-size: 1.62rem;
  font-weight: 700;
  letter-spacing: -.035em;
  line-height: 1.05;
  font-variant-numeric: tabular-nums;
}
.tile-temp .u { font-size: .82rem; font-weight: 600; color: var(--ink-3); }
.tile-temp .lbl {
  margin-left: auto;
  font-size: .64rem;
  font-weight: 700;
  letter-spacing: .05em;
  text-transform: uppercase;
  color: var(--ink-3);
  align-self: flex-end;
  padding-bottom: 3px;
}

.tile-flags { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 7px; min-height: 19px; }
/* ⚠ AND ON A CARD IT WRAPS ITS OWN WORDS RATHER THAN BEING CUT. `max-width` above stops a
   badge escaping the card; on its own that would clip "BENCH — NO CONTROL" to "BENCH — NO
   CONT…", which is a warning that no longer says what it is warning about. A card has
   vertical room and no horizontal room, so the longest badges take a second line here. The
   short ones — CALLING, AT TARGET, the "n min ago" — are nowhere near the limit and are
   untouched. Badges outside a card keep `nowrap`: their rows are wide enough. */
.tile-flags .badge { white-space: normal; text-align: left; }

/* The card's own power button, bottom-right, exactly where Smart Life puts it. */
/* ⚠ THE FULL-BLEED OPENER, BEHIND EVERYTHING — the same shape as .scene-open. The card is a
   plain box now, so this is what makes the whole of it open the zone, and it sits UNDER the
   power button rather than around it. See zoneTile() for why that distinction is the point. */
.tile-open {
  position: absolute; inset: 0; z-index: 0;
  padding: 0; border: 0; background: transparent;
  border-radius: inherit;
  cursor: pointer;
}
/* 40 + 2 either side = 44. Already positioned, so the pseudo anchors here — see .icon-btn.
   The room is there because this sits in a card corner with the card's own padding around it.
   ⚠ z-index ABOVE the opener, or the card would swallow every press meant for power. */
.tile-pwr {
  position: absolute;
  z-index: 1;
  top: 11px; right: 11px;
  width: 40px; height: 40px;
  display: grid;
  place-items: center;
  border: 1.5px solid var(--rule);
  border-radius: var(--r-pill);
  background: var(--panel);
  color: var(--ink-3);
}
.tile-pwr::after { content: ""; position: absolute; inset: -3px; border-radius: inherit; }
.tile-pwr svg { width: 18px; height: 18px; }
.tile-pwr.on {
  background: var(--mode, var(--accent));
  border-color: var(--mode, var(--accent));
  color: var(--accent-ink);
}
.tile-pwr:active { transform: scale(.9); }
.tile-pwr.busy { animation: pulse 1.1s ease-in-out infinite; }
.tile-pwr[aria-disabled="true"] { opacity: .45; }

/* A group's master switch. Same language as a zone tile's power button - filled when
   anything in the group is on - but a flex item in the heading rather than pinned to a card,
   so it cannot use .tile-pwr's absolute positioning. */
/* ⚠ A VISIBLE PILL CANNOT BE GROWN WITH PADDING — that redraws it bigger. The hit area is
   extended with a pseudo-element instead: it paints nothing, it is not clipped (neither
   .unit-head nor .tile sets overflow), and a press on it targets this button because a
   pseudo-element has no identity of its own to receive the event. 34 + 5 either side = 44.
   ⚠ position: relative IS LOAD-BEARING. Without it the inset resolves against whatever ancestor
   happens to be positioned, and the hit area lands somewhere else on the screen entirely —
   which is worse than the small target it replaced, because it would be a control that works
   where nothing is drawn. */
/* ⚠ THE PSEUDO-ELEMENT TRICK NEEDS ROOM ON BOTH SIDES, AND THIS ONE ONLY HAD IT ON ONE.
   This was a 40px disc reaching 44 with `::after { inset: -2px }`, on the rule that a painted
   control extends "by the couple of pixels its own spacing can spare". It had `margin-right:
   2px` and no margin at all on the left, where `.unit-fold` sits flush against it — so the
   left half of the extension reached into its neighbour. Measured on a group heading at
   375px: this control answered to 42x43, still under the bar, and `.unit-fold` beside it
   painted 253 and answered to 252. The one pixel it took was taken from "open this group" by
   "switch this whole group off".

   ⚠ AND IT WAS INVISIBLE UNTIL TODAY. `.unit-pwr` is built for the GROUP heading only, and
   the harness seeded its groups with the wrong field name, so no fixture group ever had a
   member and no group heading was ever drawn. A sweep of every screen reported this row clean
   because the row did not exist.

   ⚠ SO THE BOX BECOMES 44 AND THE DISC IS DRAWN INSIDE IT — the same move `.switch[role=
   "switch"]` already makes, and the reason the drawing is unchanged: `::before` at `inset:
   2px` is the identical 40px pill in the identical place. The 6px comes out of the flexible
   title beside it, which had 253 and now has 247. Measured after: all three controls on the
   row paint 44 and answer to 44, and neither neighbour loses a pixel. */
.unit-pwr {
  flex: 0 0 auto;
  position: relative;
  width: 44px; height: 44px;
  display: grid;
  place-items: center;
  /* Both sides now, so nothing here can reach into a neighbour even if it grows again. */
  margin: 0 2px;
  border: 0;
  background: none;
  color: var(--ink-3);
  cursor: pointer;
}
/* The 40px pill this control used to BE. `z-index: -1` keeps it behind the glyph; `.unit-head`
   is sticky with a z-index of its own, so that cannot fall behind the heading's background. */
.unit-pwr::before {
  content: "";
  position: absolute;
  inset: 2px;
  border-radius: var(--r-pill);
  border: 1.5px solid var(--rule);
  background: var(--panel);
  z-index: -1;
}
/* ⚠ THE `::after { inset: -2px }` THAT USED TO BE HERE IS GONE, not moved. It existed to buy
   the 4px this box now has for real, and leaving it would put the overreach straight back —
   measured at 48px across a 44px control, two of them in each gap. */
.unit-pwr svg { width: 17px; height: 17px; }
.unit-pwr.on { color: var(--accent-ink); }
.unit-pwr.on::before { background: var(--accent); border-color: var(--accent-text); }
.unit-pwr:active { transform: scale(.9); }

/* ── badges ─────────────────────────────────────────────────────────────────── */

.badge {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 2px 7px;
  border-radius: 7px;
  font-size: .64rem;
  font-weight: 700;
  letter-spacing: .03em;
  text-transform: uppercase;
  white-space: nowrap;
  /* ⚠ A BADGE MUST NOT LEAVE THE BOX IT LABELS. `nowrap` with no ceiling let the longest
     ones grow past their container and, since nothing here clips, get PAINTED OUTSIDE THE
     CARD — over the gutter, and over the page edge in the right-hand column. Measured at
     375px on 1 Sep 2026: "BENCH — NO CONTROL" 7px past the card's border, "EQUIPMENT FAULT
     9999" 10px past. Those are the badges on the units you are least allowed to get wrong. */
  max-width: 100%;
  min-width: 0;
}
.badge svg { width: 11px; height: 11px; }
.b-info { background: var(--accent-soft); color: var(--accent-text); }
.b-ok   { background: var(--ok-soft);     color: var(--ok); }
.b-warn { background: var(--warn-soft);   color: var(--warn); }
.b-crit { background: var(--crit-soft);   color: var(--crit); }
.b-flat { background: var(--panel-2);     color: var(--ink-3); }
.badge.pulse { animation: pulse 1.1s ease-in-out infinite; }

/* ── panels and rows ────────────────────────────────────────────────────────── */

.panel {
  border-radius: var(--r-card);
  background: var(--panel);
  box-shadow: var(--shadow-card);
  overflow: hidden;
}
.panel.pad { padding: 14px; }

.rowset > * + * { border-top: 1px solid var(--rule-soft); }

.row {
  display: flex;
  align-items: center;
  gap: 11px;
  width: 100%;
  min-height: 54px;
  padding: 11px 14px;
  border: 0;
  background: none;
  text-align: left;
  color: inherit;
}
button.row:active { background: var(--panel-2); }
/* The main action of a row that also carries its own controls — see the note in the
   Schedules sheet. It takes over the row's flex line so the layout is the row's, not its
   own, and it draws nothing: the row around it is what a reader sees. */
/* ⚠ `align-self: stretch`, FOR THE SAME REASON AS `.pick-main` AND FOUND THE SAME WAY.
   This row was restructured on 1 Sep 2026 to stop a button being nested inside a button, and
   the delete control beside it was widened to 44x44 in that pass. THIS half — the main
   target, the one that opens the schedule — was never measured and stayed at its text height:
   260x34 in a 66px row. Fixing one control on a row and not looking at the other is now the
   fourth sighting of the same mistake, and three of the four times the neighbour was already
   correct. The row's content box is exactly 44px, so this fills it and nothing moves. */
.row-open {
  flex: 1;
  min-width: 0;
  display: flex;
  align-items: center;
  align-self: stretch;
  gap: 11px;
  padding: 0;
  border: 0;
  background: none;
  text-align: left;
  color: inherit;
}
/* ⚠ TWO RULES, NOT ONE SELECTOR LIST. A selector list containing one selector the browser
   cannot parse drops the WHOLE rule, so pairing these would take the press feedback with it
   anywhere `:has()` is missing. Split, the second is simply ignored there and the first still
   lights the part of the row you actually pressed. */
.row-open:active { background: var(--panel-2); }
.row:has(> .row-open):active { background: var(--panel-2); }
.row-ico {
  flex: 0 0 auto;
  width: 34px; height: 34px;
  display: grid;
  place-items: center;
  border-radius: 10px;
  background: var(--panel-2);
  color: var(--ink-2);
}
.row-ico svg { width: 18px; height: 18px; }
.row-main { flex: 1; min-width: 0; }
.row-title {
  font-size: .92rem;
  font-weight: 600;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.row-sub { font-size: .76rem; color: var(--ink-3); }
.row-sub.wrap { white-space: normal; }

/* ⚠ A ROW CARRYING BOTH AN ICON AND A BADGE HAS THE NARROWEST TEXT COLUMN IN THE APP.
   Measured 27 Aug 2026 across five screens: 227px against a median of 272 and a widest of
   336, because `.row-ico` and the badge are both fixed and `.row-main` pays for both. That
   is fine for a label and wrong for a sentence - the sign-in status line wrapped at 23
   characters, inside the 23-29 band the 1.1.67 sweep was written to remove.

   `.row.stack` lets the sentence drop below the badge instead of squeezing past it: the
   first line stays icon - title - badge, the sentence goes underneath at the full width of
   the card. It has to be a SIBLING of `.row-main` rather than a child, because a child
   cannot escape the column its parent was given. Same idea as `.kv-note`. */
.row.stack { flex-wrap: wrap; }
.row.stack > .row-sub-full {
  flex: 1 0 100%;
  margin-top: 4px;
}
.row-val { font-size: .84rem; color: var(--ink-2); font-weight: 600; flex: 0 0 auto; }
.row-go {
  flex: 0 0 auto;
  width: 8px; height: 8px;
  border-right: 2px solid var(--ink-3);
  border-bottom: 2px solid var(--ink-3);
  transform: rotate(-45deg);
  opacity: .8;
}

/* ⚠ LEFT BY DEFAULT, AND THE TITLE CENTRES ITSELF. Centring the whole box was fine while
   an empty state was one short line, and half of them are now three paragraphs. The
   first attempt at this only left-aligned `.empty p` — which missed every body passed as
   a bare string rather than as markup, and that is most of them: Smart and Zone
   Restrictions both stayed centred and still measured 29 and 23 characters a line.
   Setting the container is what covers both shapes without touching a single caller. */
.empty {
  padding: 26px 20px;
  text-align: left;
  color: var(--ink-3);
  font-size: .84rem;
  line-height: 1.5;
}
/* ⚠ THE FIRST CHILD ONLY — AND `> b` WAS NOT ENOUGH, WHICH TOOK A SECOND SIGHTING TO SEE.
   This was `.empty b` once, which caught every bold word in the BODY and gave each one a
   centred line of its own. On Auto Changeover the sentence "this app has done it since v0.4,
   on the Mode row of any zone page" rendered as three separate centred blocks with the full
   stop stranded alone on a fourth. It did not look like a layout fault, it looked like the
   copy had been written that way.

   ⚠ NARROWING IT TO `> b` FIXED ONLY THE EMPTY STATES WHOSE BODY IS WRAPPED IN A <p>. Where
   the body is passed as bare inline text — which several of them do — every bold word in it
   is ALSO a direct child, so the rule went on catching them. Seen again on 1 Sep 2026 on
   Home's "No favourites yet": the word "All", mid-sentence, was rendered as its own centred
   bold line with the sentence broken in half around it.

   The title is always the first thing in the box, so that is what the rule should say. It
   now describes the element's ROLE rather than a structure that only some callers happen to
   have — the same mistake as a guard that names the shapes it knows instead of its class. */
.empty > b:first-child {
  display: block;
  text-align: center;
  margin-bottom: 5px;
  color: var(--ink);
  font-size: .95rem;
}
/* ⚠ THE HEADING IS CENTRED; THE EXPLANATION UNDER IT IS NOT. Centring is for one short
   line. Three paragraphs of it, on a phone, gives every line a ragged start and a ragged
   end and no edge for the eye to return to — measured here at 20 to 29 characters a line
   where comfortable reading is 45 to 75. The block stays centred; the words read left. */
.empty p:last-child { margin-bottom: 0; }

.boot {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  padding: 60px 20px;
  color: var(--ink-3);
  font-size: .88rem;
}
.spin {
  width: 17px; height: 17px;
  border: 2px solid var(--rule);
  border-top-color: var(--accent-text);
  border-radius: 50%;
  animation: spin 700ms linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes pulse { 50% { opacity: .42; } }

/* ── buttons ────────────────────────────────────────────────────────────────── */

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 7px;
  min-height: 46px;
  padding: 0 18px;
  border: 1px solid var(--rule);
  border-radius: var(--r-field);
  background: var(--panel);
  color: var(--ink);
  font-size: .9rem;
  font-weight: 650;
}
.btn:active { transform: scale(.98); }
.btn svg { width: 17px; height: 17px; }
.btn.primary { background: var(--accent); border-color: var(--accent-text); color: var(--accent-ink); }
.btn.danger  { background: var(--crit);   border-color: var(--crit);   color: var(--accent-ink); }
.btn.ghost   { background: var(--panel-2); border-color: transparent; color: var(--ink-2); }
.btn.wide    { width: 100%; }
.btn:disabled { opacity: .45; }
.btn:disabled:active { transform: none; }

/* All On / All Off — two big targets, and both confirm before they fire. */
.allbar { display: flex; gap: var(--gap); margin-bottom: 4px; }
.allbar .btn { flex: 1; }

/* ── fields ─────────────────────────────────────────────────────────────────── */

.search {
  width: 100%;
  min-height: 44px;
  margin-bottom: var(--gap);
  padding: 0 15px;
  border: 1px solid transparent;
  border-radius: var(--r-pill);
  background: var(--panel);
  box-shadow: var(--shadow-card);
  font-size: 1rem;                 /* 16px+ or iOS zooms the whole page on focus */
}
.search::placeholder { color: var(--ink-3); }

.field { display: block; margin-bottom: 13px; }
.field > span {
  display: block;
  margin-bottom: 5px;
  font-size: .76rem;
  font-weight: 650;
  color: var(--ink-2);
}
.field input, .field select, .field textarea {
  width: 100%;
  min-height: 46px;
  padding: 10px 13px;
  border: 1px solid var(--rule);
  border-radius: var(--r-field);
  background: var(--bg);
  font-size: 1rem;
}
.field textarea { min-height: 76px; resize: vertical; }

/* ⚠ A LABEL FOR A GROUP OF ROWS, and it is not a <label> because it names three radio
   rows rather than one input - a <label> pointing at a group is not something a browser
   does anything useful with. Reported by Daniel from a screenshot of the invite form: the
   field above it said "Their email address", the select below it said "Which site they
   belong to", and the most important choice on the screen said nothing at all and began
   with no gap, so the two read as one run-on block. Same type as `.field > span` so a
   labelled group and a labelled input read as the same kind of thing; the top margin is
   the 11px every other panel on that screen already had. */
.field-label {
  display: block;
  margin: 11px 2px 5px;
  font-size: .76rem;
  font-weight: 650;
  color: var(--ink-2);
}

/* A password field with a reveal control. The button sits over the input's right-hand end
   rather than beside it, so the field keeps its full width and the row does not grow taller
   than every other field in the sheet. */
.field-reveal { position: relative; }
/* ⚠ The padding is what stops a long password running under the button. It is the button's
   width, not a guess — change one and change the other. */
.field-reveal input { padding-right: 46px; }
.reveal {
  position: absolute;
  top: 0; right: 0;
  width: 46px; height: 100%;
  display: grid;
  place-items: center;
  padding: 0;
  border: 0;
  background: none;
  color: var(--ink-3);
  border-radius: 0 var(--r-field) var(--r-field) 0;
}
.reveal:hover { color: var(--ink-1); }
.reveal[aria-pressed="true"] { color: var(--accent-text); }

.switch {
  position: relative;
  flex: 0 0 auto;
  width: 48px; height: 29px;
  border-radius: var(--r-pill);
  background: var(--rule);
  transition: background .18s ease;
}
.switch::after {
  content: "";
  position: absolute;
  top: 2.5px; left: 2.5px;
  width: 24px; height: 24px;
  border-radius: 50%;
  background: #fff;
  box-shadow: 0 1px 3px rgb(0 0 0 / .28);
  transition: transform .18s ease;
}
.switch.on { background: var(--accent); }
.switch.on::after { transform: translateX(19px); }

/* ⚠ THE SWITCH IS SOMETIMES THE CONTROL AND SOMETIMES ONLY THE PICTURE OF ONE, AND ONLY ONE
   OF THOSE NEEDS A TAP TARGET.
   Six places draw this. In three of them the span is decoration inside a button that is
   already 54px tall — `aria-hidden`, no role, the row is what you tap. In the other three it
   IS the control: `role="switch"` and `tabindex="0"` on the span itself, and nothing else
   around it responds. Those three were 48x30 — the schedule on/off on Smart, "select every
   zone on this unit" in the group, schedule and Setback zone pickers (ten of them on one
   screen), and "keep me signed in" on the sign-in sheet.
   ⚠ THE ATTRIBUTE, NOT THE CLASS, IS WHAT SEPARATES THEM. `.scene-sw` would have caught one
   of the three. `[role="switch"]` is the same test the app itself uses to decide whether the
   span gets a click handler, so the two cannot drift apart.
   ⚠ AND IT GROWS INWARDS, NOT OUTWARDS. These sit in stacked rows: padding with a negative
   margin — the trick `.scene-del` uses safely in a card corner — would hang 7px into the row
   above and below, where the neighbour is ANOTHER unit's select-all switch. The row is
   already at least 54px tall, so a 44px box fits inside it with room to spare and reaches
   past nothing. The track moves to `::before` so every drawn pixel stays where it was. */
.switch[role="switch"] {
  height: 44px;
  background: none;                     /* the track is drawn below now */
}
.switch[role="switch"]::before {
  content: "";
  position: absolute;
  inset: 7.5px 0;                       /* 44 - 7.5 - 7.5 = the same 29px track */
  border-radius: var(--r-pill);
  background: var(--rule);
  transition: background .18s ease;
}
.switch[role="switch"].on { background: none; }
.switch[role="switch"].on::before { background: var(--accent); }
/* The knob is centred in the taller box rather than 2.5px from its top. (44 - 24) / 2 = 10.
   `left` and the `.on` slide are untouched, so the travel is the same 19px. */
.switch[role="switch"]::after { top: 10px; }

/* Segmented control — mode, fan, ranges */
.seg {
  display: flex;
  gap: 6px;
  padding: 4px;
  border-radius: var(--r-field);
  background: var(--panel-2);
}
.seg.wrap { flex-wrap: wrap; }
.seg button {
  flex: 1 1 auto;
  min-width: 56px;
  /* ⚠ 44, NOT 40. Every .seg button shipped 4px under this project's own tap-target
     bar — the chart ranges on the zone and group pages, and BOTH pickers on Options.
     One class, three screens, and it read as deliberate because they all agreed. */
  min-height: 44px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 5px;
  border: 0;
  border-radius: 9px;
  background: none;
  color: var(--ink-2);
  font-size: .82rem;
  font-weight: 650;
}
.seg button.on {
  background: var(--panel);
  color: var(--ink);
  box-shadow: var(--shadow-card);
}
.seg button:disabled { opacity: .4; }
.seg button svg { width: 16px; height: 16px; }

.ctl-label {
  margin: 17px 2px 8px;
  font-size: .72rem;
  font-weight: 700;
  letter-spacing: .06em;
  text-transform: uppercase;
  color: var(--ink-3);
}

/* ⚠ fit-content, NOT auto. With `auto` the label column sizes to the LONGEST label and
   every row pays for it: on the unit page "ClimateSync firmware, as reported" took the
   column out to 33 characters, leaving 84px for the values, so "v1.4.0 CM-Connect" broke
   across two lines and a note underneath it wrapped at thirteen characters. fit-content
   still sizes to the content, but stops at 52% and lets the long label wrap instead. */
dl.kv { display: grid; grid-template-columns: fit-content(52%) 1fr; gap: 8px 14px; margin: 0; }
dl.kv dt { font-size: .8rem; color: var(--ink-3); }
dl.kv dd { margin: 0; font-size: .84rem; font-weight: 600; text-align: right; }
/* A qualifier belonging to the row above it — for when the value alone would be read as
   answering a bigger question than it does. See the firmware rows on the unit page.

   ⚠ A ROW OF ITS OWN, SPANNING BOTH COLUMNS, BECAUSE IT IS A SENTENCE. It used to be a
   div INSIDE the <dd>, which meant forty words of explanation inheriting the value
   column's width and its right alignment: measured on the unit page at 84px wide, 13
   characters a line, thirteen lines for one sentence, every line ending ragged on the
   left. Nothing was wrong with the words. */
dl.kv .kv-note {
  grid-column: 1 / -1;
  margin-top: -4px;
  text-align: left;
  font-size: .74rem; font-weight: 400; color: var(--ink-3);
  line-height: 1.5;
}

/* ⚠ A LABEL AND A SENTENCE ARE NOT A LABEL AND A VALUE, and one grid cannot serve both.
   The About page used dl.kv for four label-then-explanation pairs and each explanation
   was squeezed into 130px at 13 to 17 characters a line — up to eleven lines, right
   aligned, for a single sentence. Same markup, same meaning, stacked instead: the label
   on its own line and the sentence under it across the full width of the card. */
dl.kv.prose { grid-template-columns: 1fr; gap: 0; }
dl.kv.prose dt {
  margin-top: 13px;
  font-size: .84rem; font-weight: 650; color: var(--ink-2);
}
dl.kv.prose dt:first-child { margin-top: 0; }
dl.kv.prose dd {
  margin-top: 2px;
  text-align: left;
  font-size: .8rem; font-weight: 400; color: var(--ink-3);
  line-height: 1.5;
}

/* ── the device page (Smart Life's device screen) ────────────────────────────── */

/* A wash of the mode colour behind the dial. It is the one place a big block of colour
   earns its keep: you can tell heating from cooling from the doorway. */
.hero {
  position: relative;
  margin: 0 calc(-1 * (var(--pad) + var(--sa-left))) 0 calc(-1 * (var(--pad) + var(--sa-right)));
  padding: 6px calc(var(--pad) + var(--sa-left)) 22px;
  background:
    radial-gradient(120% 78% at 50% -12%,
      color-mix(in srgb, var(--mode, var(--accent)) 26%, transparent) 0%,
      transparent 72%);
}
.hero.off { --mode: var(--off); }

.dial-wrap { display: flex; flex-direction: column; align-items: center; gap: 14px; }

/* ⚠ THE COLUMN OWNS THE WIDTH, not the things inside it. `max-width: 100%` on the alert
   used to be circular: its parent shrink-wrapped to its widest child, so the pill was setting
   the very limit it was being measured against and ran 26px wider than the ring. One width,
   declared once, inherited by everything under the dial. */
.dial-col { --dial-w: min(272px, 74vw); width: var(--dial-w); }

.dial {
  position: relative;
  width: var(--dial-w);
  aspect-ratio: 1;
  touch-action: none;
  user-select: none;
}
.dial svg { width: 100%; height: 100%; display: block; transform: rotate(0deg); }
.dial-track {
  fill: none;
  stroke: color-mix(in srgb, var(--ink-3) 22%, transparent);
  stroke-width: 15;
  stroke-linecap: round;
}
.dial-fill {
  fill: none;
  stroke: var(--mode, var(--accent));
  stroke-width: 15;
  stroke-linecap: round;
  transition: stroke-dasharray .16s ease;
}
.dial-knob {
  fill: var(--panel);
  stroke: var(--mode, var(--accent));
  stroke-width: 4;
}
.dial.dial-off .dial-fill,
.dial.dial-off .dial-knob { stroke: var(--off); }

.dial-mid {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 1px;
  /* ⚠ NUDGED DOWN ON PURPOSE, BUT ONLY A LITTLE. The block is what gets centred, the eye
     centres on the big number, and the two are not the same point. Most of that gap is now
     closed by putting the mode chip above the number rather than below it; this is the
     remainder. Half the padding shifts the block down, so keep it small — every pixel here
     is a pixel taken off the bottom row's clearance inside the arc. */
  padding-top: 12px;
  pointer-events: none;
  text-align: center;
}
.dial-set {
  position: relative;
  display: flex;
  align-items: flex-start;
  /* ⚠ Scales with the dial. The ring is `min(272px, 74vw)`, so on a small phone it shrinks
     while a fixed 3.5rem number does not — and the five-row stack then runs out of arc at the
     bottom. Tying the number to the viewport keeps the whole group in proportion. */
  font-size: clamp(2.9rem, 12vw, 3.5rem);
  font-weight: 700;
  letter-spacing: -.045em;
  line-height: 1;
  font-variant-numeric: tabular-nums;
}
/* ⚠ TAKEN OUT OF THE FLOW so it cannot drag the number off centre. In the flow the unit is
   part of the centred box, which pushed "23" about eight pixels left of the ring's axis —
   small, and the first thing you see once you notice it. */
.dial-set sup {
  position: absolute;
  left: 100%;
  top: .16em;
  margin-left: .06em;
  font-size: .3em;
  font-weight: 650;
  color: var(--ink-3);
}
.dial-cap {
  font-size: .64rem;
  font-weight: 700;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--ink-3);
}
/* Room and floor share one line: a second line would push the number further up the ring,
   and the two readings belong together anyway. */
.dial-reads {
  margin-top: 5px;
  display: flex;
  align-items: center;
  justify-content: center;
  /* Stacked, room above floor. One line each keeps every reading well inside the arc, at the
     cost of the setpoint riding a little higher — see the nudge on .dial-mid. */
  flex-direction: column;
  gap: 1px;
  max-width: 84%;
  /* ⚠ The dial is `min(272px, 74vw)` but type does not shrink with it. Stacked, the risk is
     vertical rather than horizontal — each extra pixel of line height pushes the mode chip
     nearer the bottom of the arc — so the readings scale with the viewport too. */
  font-size: clamp(.68rem, 2.9vw, .82rem);
  font-weight: 600;
  color: var(--ink-2);
}
.dial-floor { color: var(--ink-3); }
/* A floor sensor that is configured but silent — the reading that caps the heating, missing. */
.dial-floor.no-read { color: var(--warn); }
/* ⚠ Same animation name and duration as the pill, applied in the same repaint, so the two
   pulse together rather than beating against each other. */
.dial-floor.limit {
  color: var(--crit);
  font-weight: 750;
  animation: floor-limit 2.4s ease-in-out infinite;
}
.dial-mode {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  margin-bottom: 5px;
  padding: 3px 10px;
  border-radius: var(--r-pill);
  background: color-mix(in srgb, var(--mode, var(--accent)) 16%, var(--panel));
  /* ⚠ THE FILL IDENTIFIES, THE TEXT HAS TO BE READ — the same split --accent learned.
     The mode colour is painted here on a 16% tint of ITSELF, the lowest-contrast
     backdrop in the app, and as text it was failing in both themes: --heat 3.13 and
     --fan 3.07 in light, --fan 3.68 and --auto 4.31 in dark. Mixing toward --ink
     rather than adding six tokens is what makes one rule correct in both themes:
     --ink flips with the theme, so this darkens in light and lightens in dark.
     70/30 clears 4.6 on every mode in both themes with the worst at 4.77.
     ⚠ THE BACKGROUND ABOVE IS DELIBERATELY UNMIXED. The tint is the identity. */
  color: color-mix(in srgb, var(--mode, var(--accent)) 70%, var(--ink));
  font-size: .72rem;
  font-weight: 700;
}
.dial-mode svg { width: 13px; height: 13px; }

/* ── the floor limit alert ───────────────────────────────────────────────────────
   Sits between the ring and the step buttons, so it lands in the eye's path on the way from
   the temperature to the control rather than off in a card below the fold. */
.floor-alert {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 7px;
  /* ⚠ Same trap as .dial-btns: a block-level flex box stretches to the dial’s width and then
     packs its contents left, so this read as a wide bar with the words shoved to one side
     instead of a pill under the ring. */
  width: fit-content;
  /* ⚠ "limit unknown" makes this the widest thing under the ring — 270px against a 272px
     dial. Cap it to the column and let it wrap onto a second line rather than run off the
     side of a phone. It shrinks back to one line the day the API sends the number. */
  max-width: 100%;
  flex-wrap: wrap;
  /* ⚠ THE DIAL BOX IS TALLER THAN THE ARC IT DRAWS. The ring sweeps 270° with its gap at the
     bottom, so the lowest ink is at 45°: 92·sin45 + half the 15 stroke = 72.6 of the 116
     radius, leaving 43.5 of the 232 viewBox — 18.7% of the box — as empty space underneath.
     Pulling that back gives the pill the same 10px above as below, and because it is a
     percentage it holds as the dial scales with the viewport. */
  margin: calc(10px - 18.7%) auto 10px;
  padding: 7px 15px;
  border: 1px solid var(--crit);
  border-radius: var(--r-pill);
  background: var(--crit-soft);
  color: var(--crit);
  font-size: .8rem;
  font-weight: 750;
  /* ⚠ 2.4s is about 0.4 Hz — deliberately far below the 3 Hz that makes a flash a seizure
     risk, and slow enough to read the words while it pulses. */
  animation: floor-limit 2.4s ease-in-out infinite;
}
.floor-alert svg { width: 15px; height: 15px; flex: 0 0 auto; }
.floor-alert .fa-temp { font-variant-numeric: tabular-nums; }
/* Shown only while the API reports that a limit tripped without saying what it was. */
.floor-alert .fa-none { font-style: normal; font-weight: 600; font-size: .92em; opacity: .72; }
/* ⚠ BOTH ENDS ARE THE LEGIBLE STATE. The reduced-motion rule collapses this to a single
   .01ms run, which lands on 100% — so 100% has to be the version somebody can read. */
@keyframes floor-limit {
  0%, 100% { opacity: 1; }
  50%      { opacity: .38; }
}

/* ⚠ justify-content matters here: this row is a block-level flex box, so it stretches to the
   dial’s width and then packed its contents hard left — the buttons sat 38px off the ring’s
   axis while their container looked perfectly centred. */
.dial-btns { display: flex; align-items: center; justify-content: center; gap: 16px; }
.dial-step {
  width: 58px; height: 58px;
  display: grid;
  place-items: center;
  border: 0;
  border-radius: var(--r-pill);
  background: var(--panel);
  box-shadow: var(--shadow-card);
  color: var(--ink);
  font-size: 1.5rem;
  font-weight: 400;
  line-height: 1;
}
.dial-step:active { transform: scale(.92); }
.dial-step:disabled { opacity: .4; }
.dial-limits { font-size: .74rem; color: var(--ink-3); }
/* The number in the floor-limit stepper sheet. */
.floor-val { min-width: 88px; text-align: center; font-size: 1.1rem; color: var(--ink-2); }
.floor-val b { font-size: 1.9rem; font-variant-numeric: tabular-nums; color: var(--ink); }
/* Hours and minutes side by side in the timer sheet — thumbs, not a text field. */
.timer-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.timer-grid .dial-btns { gap: 6px; }
.timer-grid .dial-step { width: 46px; height: 46px; font-size: 1.25rem; }
.timer-grid .floor-val { min-width: 44px; }
.timer-cap {
  text-align: center;
  font-size: .68rem;
  font-weight: 700;
  letter-spacing: .09em;
  text-transform: uppercase;
  color: var(--ink-3);
  margin-bottom: 6px;
}
/* The picked row in a pickSheet. */
.row.row-on .row-title { color: var(--accent-text); }
.row.row-on .row-val { color: var(--accent-text); }
.row:disabled { opacity: .45; }
/* A toggle row is a button now; it must still read as a row, not a raised control. */
.row-toggle { width: 100%; border: 0; background: transparent; color: inherit; text-align: left; font: inherit; }
.row-toggle:active { background: var(--panel-2); }

/* The zone picker's device headings. Tinted and bolder so the list reads as several short
   lists rather than one long one, and the zones under each are indented enough to belong to
   it without losing the switch's alignment down the right edge. */
.row-device { background: var(--panel-2); padding: 0 14px 0 0; }
.row-device .row-title { font-weight: 700; }
.row-zone { padding-left: 26px; }

/* The heading's two targets. The fold takes all the width it can so the name and the count
   are both hittable on a phone; the switch keeps its own 48px at the end. */
.row-fold {
  flex: 1;
  min-width: 0;
  display: flex;
  align-items: center;
  gap: 11px;
  padding: 11px 0 11px 14px;
  border: 0;
  background: none;
  color: inherit;
  text-align: left;
  font: inherit;
}
.row-fold:active { background: var(--panel-2); }
/* Points down when the device is open and right when it is folded — the same one-glance rule
   the unit groups on Home use, so the two lists do not contradict each other. */
.row-caret { flex: 0 0 auto; line-height: 0; opacity: .55; }
.row-caret svg { transform: rotate(90deg); transition: transform 140ms ease; }
.row-device.folded .row-caret svg { transform: rotate(0deg); }

/* Delete, on the card or row it belongs to. Muted until touched — it is the destructive one,
   so it should be findable without being the thing your thumb lands on first. */
.scene-del, .row-del {
  flex: 0 0 auto;
  width: 32px; height: 32px;
  display: grid;
  place-items: center;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: transparent;
  color: var(--ink-3);
}
.scene-del:active, .row-del:active { background: var(--crit-soft); color: var(--crit); }
/* ⚠ THE TAP TARGET, NOT THE DRAWING. This button deletes a schedule and was 32x32 — the
   smallest control in the app and the most destructive one. It still LOOKS 32px; the padding
   below extends what a thumb can hit to 44, which is what Apple's guidance asks and roughly
   what a finger actually is. The negative margin keeps the layout exactly where it was. */
.scene-del {
  position: absolute; right: 8px; bottom: 8px;
  /* ⚠ content-box, OR THE PADDING DOES NOTHING. Everything in this sheet is border-box, so
     padding on a 32px box is spent INSIDE the 32px and the tap target does not grow by a
     pixel. Measured in the page: still 32x32 with the padding applied, while .eye-btn — which
     had the box-sizing — came out 46. A rule can be present, correct-looking, and inert. */
  box-sizing: content-box;
  padding: 6px; margin: -6px;
}
/* ⚠ THE OTHER HALF OF THAT RULE NEVER GOT THE FIX. `.scene-del` and `.row-del` are declared
   together above, at 32x32, and only `.scene-del` is widened below it — so the delete button
   in the Schedules SHEET, reached from any zone or group page, has been answering to 32x32
   the whole time. Measured 1 Sep 2026. Same job, same icon, same consequence.
   ⚠ AND IT MUST NOT BE FIXED THE SAME WAY. `.scene-del` is absolutely positioned in the
   corner of a card, so padding with a negative margin reaches into that card's own padding
   and touches nothing. `.row-del` sits at the end of a stacked row; the same 6px would hang
   into the rows above and below, where the neighbour at that exact x is the NEXT schedule's
   delete button — a near miss would not fail, it would delete the wrong schedule. The row is
   56px tall, so a real 44px box fits inside it and reaches past nothing. The trash glyph
   stays 15px and the background is transparent until pressed, so nothing looks different;
   the press feedback is simply the size of the thing you actually hit. */
.row-del { width: 44px; height: 44px; }
.scene { position: relative; }
.inert-note { margin-top: 10px; }

/* The big power button on the device page */
.pwr-big {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 9px;
  min-height: 52px;
  width: 100%;
  margin: 4px 0 2px;
  border: 1.5px solid var(--rule);
  border-radius: var(--r-field);
  background: var(--panel);
  color: var(--ink-2);
  font-size: .92rem;
  font-weight: 700;
  letter-spacing: .02em;
}
.pwr-big svg { width: 19px; height: 19px; }
/* ⚠ THE ONLY CONTROL IN THE APP THAT PUTS WORDS ON A MODE COLOUR, WHICH IS WHY IT ALONE
   MIXES ITS FILL. The ink swap below is enough everywhere else: the two small power
   buttons carry an icon, which is held to 3:1 and clears it. This one says "ON — tap to
   switch off" at 15.6px, needs 4.5, and in the light theme white on --heat is 3.76 and
   on --fan is 3.65 — so the ink alone could not save it and the fill had to move too.
   ⚠ MIXED TOWARD --ink, NOT TOWARD BLACK, and that is the whole reason one number works
   for both themes: --ink is near-black in light and near-white in dark, so this darkens
   the fill where the ink is pale and lightens it where the ink is dark. 80/20 clears 4.6
   on all ten fills in both themes, worst 4.86. The mode is still plainly the mode. */
.pwr-big.on {
  background: color-mix(in srgb, var(--mode, var(--accent)) 80%, var(--ink));
  border-color: color-mix(in srgb, var(--mode, var(--accent)) 80%, var(--ink));
  color: var(--accent-ink);
}
.pwr-big:active { transform: scale(.985); }
.pwr-big:disabled { opacity: .5; }

/* ── chart ──────────────────────────────────────────────────────────────────── */

.chart-card { padding: 14px 12px 10px; }
.chart-head {
  display: flex;
  align-items: center;
  gap: 8px;
  margin: 0 2px 10px;
}
.chart-title { font-size: .88rem; font-weight: 700; }
.chart-legend {
  display: flex;
  gap: 12px;
  margin: 8px 2px 0;
  font-size: .72rem;
  color: var(--ink-3);
}
.chart-legend span { display: inline-flex; align-items: center; gap: 5px; }
.chart-legend i {
  width: 13px; height: 3px;
  border-radius: 2px;
  background: currentColor;
}
.chart-legend .room { color: var(--accent-text); }
.chart-legend .set  { color: var(--haux); }
.chart-box { position: relative; width: 100%; }
/* Sized in real pixels by chart.js, so no scaling and no squashed labels. */
.chart-box svg { display: block; max-width: 100%; }
.chart-grid { stroke: var(--rule-soft); stroke-width: 1; }
.chart-axis { fill: var(--ink-3); font-size: 9px; font-family: var(--sans); }
.chart-room { fill: none; stroke: var(--accent); stroke-width: 2; stroke-linejoin: round; }
.chart-room-area { fill: color-mix(in srgb, var(--accent) 12%, transparent); stroke: none; }
.chart-set  { fill: none; stroke: var(--haux); stroke-width: 1.8; stroke-dasharray: 5 4; }
.chart-on   { fill: color-mix(in srgb, var(--ok) 20%, transparent); stroke: none; }
/* ⚠ THE GROUP CHART'S LINES CARRY NO FILL AND NO DOT. Eight translucent areas stacked on one
   axis is a fog the bottom line cannot be read through, and eight end-dots at eight different
   heights read as data points rather than as line ends. The stroke colour is set inline from
   GROUP_SERIES_COLOURS, in the order the legend repeats. */
.chart-zone { fill: none; stroke-width: 1.8; stroke-linejoin: round; }
/* The zone legend wraps: eight names do not fit on one phone line, and truncating them would
   leave colours nobody can name. */
.chart-legend.zones { flex-wrap: wrap; row-gap: 4px; }
.chart-legend.zones span { color: var(--ink-3); }

/* ── schedule cards (Smart Life's scene cards) ───────────────────────────────── */

.scenes { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: var(--gap); }
.scene {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 3px;
  padding: 15px;
  border-radius: var(--r-tile);
  background:
    linear-gradient(135deg,
      color-mix(in srgb, var(--mode, var(--accent)) 20%, var(--panel)),
      var(--panel) 78%);
  box-shadow: var(--shadow-card);
  text-align: left;
  color: inherit;
}
.scene.off { --mode: var(--off); opacity: .78; }
.scene-when {
  font-size: 1.32rem;
  font-weight: 700;
  letter-spacing: -.03em;
  font-variant-numeric: tabular-nums;
}
.scene-what { font-size: .82rem; font-weight: 600; color: var(--ink-2); }
.scene-days { font-size: .72rem; color: var(--ink-3); }
.scene-sw { position: absolute; top: 14px; right: 14px; }
/* The card's own "open it" target: the whole card, underneath the switch and the bin so those
   two keep their hit areas. Sits below them rather than wrapping them — see the note in
   screens.js about never nesting a button in a button. */
.scene-open {
  position: absolute; inset: 0; z-index: 0;
  padding: 0; border: 0; background: transparent;
  border-radius: inherit;
  cursor: pointer;
}
.scene-sw, .scene-del { z-index: 1; }

/* ── sheets ─────────────────────────────────────────────────────────────────── */

.sheet-host {
  position: fixed;
  inset: 0;
  z-index: 60;
  display: flex;
  align-items: flex-end;
  justify-content: center;
  background: rgb(8 12 18 / .5);
  backdrop-filter: blur(3px);
  -webkit-backdrop-filter: blur(3px);
  animation: fade .16s ease;
}
@keyframes fade { from { opacity: 0; } }

.sheet {
  width: 100%;
  max-width: 560px;
  max-height: 92vh;
  display: flex;
  flex-direction: column;
  border-radius: 22px 22px 0 0;
  background: var(--bg);
  box-shadow: var(--shadow-lift);
  animation: rise .22s cubic-bezier(.22, 1, .36, 1);
  padding-bottom: var(--sa-bottom);
}
@keyframes rise { from { transform: translateY(14%); opacity: .4; } }

.sheet-grip {
  width: 38px; height: 4px;
  margin: 9px auto 0;
  border-radius: 2px;
  background: var(--rule);
  flex: 0 0 auto;
}
.sheet-head {
  display: flex;
  align-items: flex-start;
  gap: 10px;
  padding: 11px 16px 9px;
  flex: 0 0 auto;
}
.sheet-head .h { flex: 1; min-width: 0; }
.sheet-head b {
  display: block;
  font-size: 1.06rem;
  font-weight: 700;
  letter-spacing: -.015em;
}
.sheet-head span { font-size: .78rem; color: var(--ink-3); }
/* The way back out of a sheet that opened over another one. Same shape as the close button
   so the pair reads as one control group, with the chevron turned to point left. */
/* ⚠ 44, AND IT MAKES THE SENTENCE ABOVE TRUE. "Same shape as the close button" was written
   here while this was 34 and `.sheet-x` was 44 — a comment describing an intention the rule
   did not carry out. The header is `align-items: flex-start` beside a control that is already
   44, so this costs no height. */
.sheet-back {
  width: 44px; height: 44px;
  flex: 0 0 auto;
  display: grid;
  place-items: center;
  border: 0;
  border-radius: var(--r-pill);
  background: var(--panel-2);
  color: var(--ink-2);
}
.sheet-back svg { transform: rotate(180deg); }
.sheet-back:active { transform: scale(.92); }
/* An action belonging to the whole sheet, beside the close button.
   ⚠ WORDS, NOT A GLYPH. "Show all" and "Hide all" are a state as much as an action, and there
   is no icon a reader would decode the same way twice — the same reason the filter controls on
   Home name their current answer instead of relying on a shape. */
/* ⚠ 44, THE FOURTH INSTANCE OF THE FAULT THE NOTE ON `.sheet-x` DESCRIBES — and it is the
   control sitting immediately beside it. That note lists `.btn.sm` at 32, `.alert-ack` at 32
   and `.sheet-x` at 34, found one after another by re-measuring; this one was in the same
   header the whole time and was not, because the sweep that found the other three did not
   open a sheet that passes an `action`. Measured 74x35 on "Hide all", 1 Sep 2026. The header
   is already 44 tall for the close button, so this changes no layout. */
.sheet-act {
  flex: 0 0 auto;
  min-height: 44px;
  padding: 0 12px;
  border: 1px solid var(--rule);
  border-radius: var(--r-pill);
  background: var(--panel);
  color: var(--ink-2);
  font-size: .78rem;
  font-weight: 650;
  white-space: nowrap;
}
.sheet-act:active { transform: scale(.94); }
.sheet-x {
  /* ⚠ 44px, AND IT IS ON EVERY SHEET IN THE APP. Third instance of the same fault in one
     evening: `.btn.sm` at 32, `.alert-ack` at 32, and this at 34 - each its own class, none
     of them found by looking at the others. The sweep that found all three measures every
     control on the page; the first two were found by fixing one and re-measuring, and this
     one by re-measuring again after that. There was no reasoning that would have got here. */
  width: 44px; height: 44px;
  flex: 0 0 auto;
  display: grid;
  place-items: center;
  border: 0;
  border-radius: var(--r-pill);
  background: var(--panel-2);
  color: var(--ink-2);
  font-size: 1.2rem;
  line-height: 1;
}
.sheet-body {
  flex: 1;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  padding: 2px 16px 18px;
}
.sheet-foot {
  flex: 0 0 auto;
  display: flex;
  padding: 11px 16px calc(11px + var(--sa-bottom));
  border-top: 1px solid var(--rule);
  background: var(--panel);
}

/* ── toasts ─────────────────────────────────────────────────────────────────── */

.toasts {
  position: fixed;
  left: 50%;
  bottom: calc(var(--tabs-h) + var(--sa-bottom) + 14px);
  z-index: 70;
  transform: translateX(-50%);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 7px;
  width: min(440px, calc(100vw - 28px));
  pointer-events: none;
}
.toast {
  width: 100%;
  display: flex;
  align-items: flex-start;
  gap: 9px;
  padding: 11px 14px;
  border-radius: var(--r-field);
  background: var(--console);
  color: var(--console-ink);
  font-size: .84rem;
  font-weight: 550;
  line-height: 1.4;
  box-shadow: var(--shadow-lift);
  transition: opacity .22s ease, transform .22s ease;
  animation: rise .2s ease;
}
.toast svg { width: 16px; height: 16px; flex: 0 0 auto; margin-top: 1px; }
.toast.ok   svg { color: #6fce9a; }
.toast.warn svg { color: #e4b95c; }
.toast.err  svg { color: #e88f75; }
.toast b { font-weight: 700; }

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

/* ⚠ `.gate-mark` WAS HERE AND IS DELETED, 1 Sep 2026 — a 66px rounded square with an
   accent-to-cool gradient, meant as a brand mark at the top of the sign-in screen. Nothing
   has ever rendered it. The screen works and reads well without one; this is a piece of
   design that was styled and never placed, not a fault, and it is removed so it stops looking
   like something that broke. If a mark is wanted there it is a deliberate decision and one
   element in `signInScreen`, not a rule to un-delete. */
.gate { padding: 8px 2px 20px; }
.gate h2 { text-align: center; font-size: 1.3rem; }
.gate p.lead {
  margin: 6px 0 22px;
  text-align: center;
  font-size: .84rem;
  color: var(--ink-3);
  line-height: 1.5;
}

/* ── desktop ────────────────────────────────────────────────────────────────────
   The same app, not a different one. Smart Life on a tablet is the phone layout with
   room to breathe, so the column widens and the grid gains columns; the bottom bar
   becomes a floating pill because a full-width bar on a 27" monitor looks like a
   mistake rather than a phone app. */

@media (min-width: 760px) {
  :root { --pad: 22px; }

  .top, .view, .netbar { max-width: 1080px; margin-inline: auto; width: 100%; }
  .view { padding-bottom: 40px; }
  .tiles { grid-template-columns: repeat(auto-fill, minmax(196px, 1fr)); }
  .hero { margin-inline: 0; border-radius: var(--r-card); }
  .app { padding-bottom: calc(var(--tabs-h) + var(--sa-bottom) + 22px); }

  .tabs {
    left: 50%;
    right: auto;
    bottom: calc(var(--sa-bottom) + 16px);
    transform: translateX(-50%);
    width: min(520px, calc(100vw - 44px));
    border-radius: var(--r-pill);
    border: 1px solid var(--rule);
    box-shadow: var(--shadow-lift);
    overflow: hidden;
  }
  .toasts { bottom: calc(var(--tabs-h) + var(--sa-bottom) + 34px); }

  .sheet-host { align-items: center; padding: 22px; }
  .sheet { border-radius: 22px; max-height: 86vh; }
  .sheet-foot { padding-bottom: 11px; }
}

@media (min-width: 1100px) {
  .tiles { grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); }
}

/* Somebody who has asked for less movement gets less movement. The pulse on a pending
   command is replaced by a static dimming, not removed — it still has to be visible. */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: .01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: .01ms !important;
  }
  .badge.pulse, .tile-pwr.busy { opacity: .6; }
}

/* A setpoint that does nothing in the current mode. Dimmed and captioned rather than hidden:
   the stored value is real, it stays adjustable, and it matters again the moment the mode
   changes — so removing the control would be a lie of a different kind. */
.hero.inert .dial-set,
.hero.inert .dial-fill,
.hero.inert .dial-knob { opacity: .45; }
.hero.inert .dial-cap { color: var(--warn); }

/* ── the group control page ────────────────────────────────────────────────────
   The zone page's hero with the drag dial taken out: one number for a whole group, or the
   word that says there is no one number. Dragging a ring across eight zones would be a
   continuous gesture over a value that does not exist until you let go. */
.grp-val {
  display: inline-flex;
  align-items: flex-start;
  line-height: 1;
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}
.grp-val b { font-size: 3.6rem; font-weight: 700; letter-spacing: -.02em; }
.grp-val .gu {
  font-size: 1.05rem; font-weight: 650; color: var(--ink-3);
  margin: .34em 0 0 .1em;
}
/* ⚠ "Mixed" IS A WORD, NOT A READING. Set in the numeral face at numeral size it would be
   read as a value at a glance, which is the one thing this page exists not to do. Smaller,
   and in the secondary ink, so the eye files it as a statement about the group rather than
   as the group's setpoint. */
.grp-val.mixed b { font-size: 2rem; font-weight: 650; color: var(--ink-2); }
.grp-spread { margin-top: 7px; }

/* ── the group's gauge ────────────────────────────────────────────────────────
   The zone page's ring, with the knob removed. ⚠ THAT REMOVAL IS THE POINT: the zone's ring is
   a drag surface and this one is a readout, so it must not wear the one part of the control
   that says "take hold of me". The steppers below it are the control.
   `touch-action`/`user-select` come from `.dial` and are harmless here; nothing listens. */
.dial-gauge { cursor: default; }
/* ⚠ Both the length AND the start of the band animate, because a mixed group's arc moves at
   both ends — a single transition on dasharray would slide one end and jump the other. */
.grp-arc { transition: stroke-dasharray .16s ease, stroke-dashoffset .16s ease; }

/* Inside the ring the number has to scale with the ring, exactly as `.dial-set` does — a fixed
   3.6rem overflows the arc on a small phone once the mode chip and the room line are stacked
   with it. */
.dial-mid .grp-val b { font-size: clamp(2.9rem, 12vw, 3.5rem); letter-spacing: -.045em; }
.dial-mid .grp-val .gu { font-size: 1.02rem; margin-top: .3em; }
/* "Mixed" is a word, not a reading — it keeps its smaller, quieter setting in here too, and
   still has to fit the arc on the narrowest phone. */
.dial-mid .grp-val.mixed b { font-size: clamp(1.6rem, 6.6vw, 2rem); letter-spacing: -.02em; }


/* ── the unit heading on Home ──────────────────────────────────────────────────
   A zone name is only unique inside its own ClimateSync — this fleet has two "Zone 1"s —
   so the unit is a heading rather than a detail, and it STICKS below the top bar while its
   zones scroll past. Losing the heading halfway down a long list is the whole problem it
   exists to solve. */

/* The heading is a row of two targets: the wide one folds the group, the chevron opens the
   ClimateSync. Padding sits on the children so both have a full-height hit area. */
.unit-head {
  position: sticky;
  top: var(--top-real);
  z-index: 12;
  display: flex;
  align-items: center;
  width: 100%;
  margin: 18px 0 9px;
  padding: 0 4px 0 0;
  border-radius: var(--r-field);
  background: color-mix(in srgb, var(--bg) 92%, transparent);
  backdrop-filter: blur(12px);
  -webkit-backdrop-filter: blur(12px);
  color: inherit;
  text-align: left;
}
/* ⚠ 44, TO MATCH THE TWO 44px BUTTONS IT SITS BETWEEN. The star and the open arrow on this
   same row were already 44; this — the part carrying the unit's NAME, and the largest thing
   on the row — was 40. The odd one out was the one people actually aim for. Two pixels of
   padding a side, and the row is one height throughout. */
.unit-fold {
  flex: 1;
  min-width: 0;
  display: flex;
  align-items: center;
  /* ⚠ THE PADDING BELOW IS SIZED FOR A UNIT HEADING, AND THIS CLASS ALSO DRAWS A GROUP ONE.
     A unit heading stacks a name and a meta line, so 6px a side clears 44 comfortably; a
     GROUP heading is one line, and the same rule measured 38px there — the target shrinking
     because the content did, which is the same shape as `.pick-main` on the Names screen.
     Stretching to the heading's own line height covers both: the heading is 44 for a group
     and 56 for a unit, with no vertical padding of its own, so this fills whichever it is
     and the unit case is left exactly as it was. Verified by measurement, not by arithmetic —
     and this time on both kinds of heading, which is what the note here used to miss. */
  align-self: stretch;
  gap: 9px;
  padding: 6px 4px 6px 9px;
  border: 0;
  border-radius: var(--r-field);
  background: transparent;
  color: inherit;
  text-align: left;
}
.unit-fold:active { background: var(--panel-2); }
/* ⚠ 44px TO A THUMB — THE SAME RULE .scene-del AND .eye-btn ALREADY FOLLOW, APPLIED TO THE REST.
   Swept in the running page on 27 Aug 2026: five more controls were under the 44px this app set
   itself, and two of them were 34x34 — the star that decides what is on Home, and the button
   that opens a unit, side by side on every heading of a 40-unit list.
   ⚠ NOT WITH A NEGATIVE MARGIN, AND THAT WAS MEASURED THE HARD WAY. The first attempt used the
   .scene-del trick — content-box padding pulled back by a negative margin. It works where a
   control has room around it and it does NOT work here: this row was already 4px over-full, so
   each expanded box reached into the next one and whichever came later in the DOM won the
   overlap. The button reported 44 wide and answered to 37, and the 7px it lost went to its
   neighbour — a press near the edge would have opened a unit instead of switching it off.
   A target that big and wrong is worse than the small one it replaced.
   ⚠ SO: WHAT PAINTS NOTHING SIMPLY BECOMES 44. This and .unit-star have no border and no
   background, so a real 44px box looks identical and the flexible title beside them gives up
   the width. A control with a VISIBLE pill can do the same by drawing that pill in a
   `::before` — see .unit-pwr, which was moved onto this footing once its own extension was
   measured reaching into a neighbour. The extension trick is still right where there is
   genuine space either side; .icon-btn has it and says so. */
.unit-open {
  flex: 0 0 auto;
  width: 44px; height: 44px;
  display: grid;
  place-items: center;
  border: 0;
  border-radius: 50%;
  background: transparent;
  color: inherit;
}
.unit-open:active { background: var(--panel-2); }
/* The star: what is on Home. Same 34px target as the chevron beside it, because a 17px
   icon is not a tap target on a phone and this one sits between two other buttons. */
.unit-star {
  flex: 0 0 auto;
  width: 44px; height: 44px;
  display: grid;
  place-items: center;
  border: 0;
  border-radius: 50%;
  background: transparent;
  color: inherit;
  opacity: .4;
}
.unit-star.on { opacity: 1; color: var(--accent-text); }
/* ⚠ `.via-site` WAS HERE AND IS DELETED, 1 Sep 2026 — DO NOT WIRE IT UP.
   It drew a third star state for "this unit is on Home because its SITE is starred", so that
   tapping it would not read as a broken button. The problem is real; the app answers it a
   different way, and has since: `paintStar` says Favourite rather than Home, and un-starring
   a unit that a site also picks toasts "still on Home under <site>". Nothing has ever added
   this class — `paintStar` assigns `className` outright, so it could not survive if something
   did — and the rule reads like an unfinished feature rather than a rejected one. It is the
   rejected one. */
.unit-star:active { background: var(--panel-2); }
/* The caret points down when the group is open and right when it is folded — the one
   convention people already read without being told. */
.unit-caret { flex: 0 0 auto; line-height: 0; opacity: .55; }
.unit-caret svg { transform: rotate(90deg); transition: transform 140ms ease; }
.unit-head.folded .unit-caret svg { transform: rotate(0deg); }
.unit-ico {
  flex: 0 0 auto;
  width: 26px; height: 26px;
  display: grid;
  place-items: center;
  border-radius: 8px;
  background: var(--panel-2);
  color: var(--ink-2);
}
/* ⚠ THE NAME AND THE META ARE STACKED, BECAUSE ON A PHONE THEY NEVER FITTED SIDE BY SIDE.
   Measured on Home at 375px, 1 Sep 2026: the meta line wanted 305px and had 90. What it
   holds is "F02A2B8A0170 · 4 zones · 2 on · last heard 29 min ago", so what a phone actually
   showed was "F02A2B8A017…" — the last character of the MAC gone (0170 and 0171 read the
   same), the zone count gone, the "N on" gone, and the amber LAST HEARD warning gone with
   them. That warning is painted live specifically so a heading cannot claim a unit is
   healthy when it has stopped answering, and it was being clipped off the end of its own
   line on the only screen size this app is designed for. Nothing was wrong with the code
   that wrote it; there was no room on the line, and the end of the line is where the
   important half was.
   Stacking gives each of them a line of their own: the meta goes from 90px to 179, and the
   name is no longer capped at 52% of a shared one. Measured cost at 375px: the heading is
   56px instead of 44 — and it is sticky, so that 12px is spent for as long as the list is on
   screen. Worth it for a warning that was never visible at all.
   ⚠ 179 IS STILL NOT 305, so `paintUnitHead` puts the warning FIRST when there is one.
   Clipping always takes the end of a line, which makes the end the wrong place for the only
   part of this that is urgent; the zone counts, which the cards below repeat, go there
   instead. */
.unit-text {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 1px;
}
.unit-name {
  font-size: .95rem;
  font-weight: 700;
  letter-spacing: -.01em;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  /* Was 52%, to stop the name eating the meta when the two shared one line. In a column it
     would truncate a name that has the whole width available. */
  max-width: 100%;
}
.unit-meta {
  min-width: 0;
  font-size: .74rem;
  color: var(--ink-3);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.unit-head .row-go { opacity: .55; }

/* The "make a group" chip. Groups were only reachable from Me, which is a fine place to
   manage them and a poor place to find out they exist. */
.chip-add {
  border: 1px dashed var(--rule);
  background: transparent;
  box-shadow: none;
  color: var(--ink-3);
  gap: 4px;
}
.chip-add svg { width: 14px; height: 14px; }

/* A unit under commissioning: readings stay, control does not. Dashed rather than hidden —
   the zone is real and its temperature is worth seeing; only the button is refused. */
.tile-pwr.held {
  border-style: dashed;
  border-color: var(--warn);
  color: var(--warn);
  background: var(--panel);
}

/* The same idea for a locked zone's Mode and Fan rows. They used to go grey, which teaches
   nothing and looks broken; they are pressable and answer with a sentence, so they are
   marked as HELD rather than dead - warn-coloured, not dimmed. */
.row.held .row-val { color: var(--warn); }
.row.held .row-ico { color: var(--warn); }

/* ── which ClimateSync a zone is on ────────────────────────────────────────────
   The M number, the unit's name and its MAC, on every card. Engineers identify a box by its
   M number; the MAC is what the API and the logs use; the name is what everyone else says.
   All three, small, on one ellipsised line. */
/* ⚠ THE THIRD ROW THAT RESERVED 44px FOR A BUTTON ON ANOTHER ROW, AND THE ONE IT COST MOST.
   `.tile-name` and `.tile-sub` lost this phantom padding on 1 Sep 2026 and the note above
   them says `.tile-unit` "has been using the full width all along, which is what proves the
   width is free". That sentence was never measured and it was wrong: this rule carried the
   same `padding-right: 44px` the whole time. A claim about a neighbour is not evidence —
   measure the neighbour.

   Measured in the page at 375px, in tile coordinates: `.tile-pwr` occupies y 12–52 and this
   line starts at y 103.5, so the button's bottom edge is 51.5px clear of it, at every width.
   The cost landed entirely on the name, because `.u-mac` below is deliberately shrink-proof
   and this line's spare width is whatever the MAC leaves: "Bench unit" needs 55px and was
   given 27 — about three characters and an ellipsis — while a 20-character name got the same
   27. Removing the padding gives the name exactly the 55 it asks for with the MAC unchanged.
   ⚠ IF A CONTROL IS EVER MOVED DOWN BESIDE THIS ONE, THE PADDING COMES BACK — but measured
   against where that control actually is, not against the corner it started in. */
.tile-unit {
  display: flex;
  align-items: baseline;
  gap: 5px;
  min-width: 0;
  margin-top: 1px;
  font-size: .69rem;
  color: var(--ink-3);
  white-space: nowrap;
  overflow: hidden;
}
.m-no {
  flex: 0 0 auto;
  padding: 0 4px;
  border-radius: 4px;
  background: var(--panel-2);
  color: var(--ink-2);
  font-weight: 700;
  font-size: .95em;
  letter-spacing: .01em;
}
/* A dashed underline where the M number was inferred from a name rather than confirmed —
   an engineer sent to the wrong plant room by a scraped string is the failure to avoid. */
.m-no.guess {
  border-bottom: 1px dashed var(--warn);
  color: var(--warn);
}
/* ⚠ THE NAME OUTRANKS THE MAC WHEN THE TILE IS NARROW, AND IT DID NOT USED TO.
   `.u-mac` was `flex: 0 0 auto` — it never shrank — so on the 2-up phone grid it took its
   full 76px out of a ~90px line and the unit NAME was left with what was over. Measured
   28 Aug 2026, "Bench unit" (needs 60px) against the common phone widths:

       375px  name got 13px      390px  name got 20px      414px  name got 32px
       383px  name got  9px  — one letter and an ellipsis

   So every zone card showed the twelve-character machine identifier in full and the human
   name not at all, on the screen somebody reads to find a room. It only bit between the
   2-up breakpoint and about 480px, which is every phone this app is built for; at 320px
   (1-up) and on a tablet it looked perfect, which is why reading the CSS never found it.

   ⚠ THE FIX IS THE SHRINK WEIGHT, NOT A WIDTH. Both may shrink now, but the MAC yields
   ~100× more readily, so the name keeps its full width until there is genuinely no room and
   the MAC is what gets clipped. Nothing is removed from the card: `.tile-unit` already
   carries the M number, the full name and the pretty MAC in its `title`. */
.tile-unit .u-name {
  flex: 0 1 auto;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* ⚠ AND WHEN THERE IS A NAME BESIDE IT, THIS IS FIVE CHARACTERS, SO IT MUST BE THE RIGHT FIVE.
   The shrink weight above settled who yields; it did not settle what survives. At 375px this
   box is about 34px, and clipping twelve characters from the right spent all of them on
   "F02A2" — identical on every unit in the fleet — while the four that tell 0170 from 0171
   were the ones thrown away. Those two units are the test bench and a customer site under a
   do-not-command hold. Worse, once a long unit name took its share of a 138px line, a shrink
   weight of 100 collapsed this to a bare "…": measured 1 Sep 2026, four of the fifteen cards
   showed an ellipsis and nothing else, 0171 among them.
   ⚠ SO THE TEXT IS SHORTENED IN JS, NOT BY THE LAYOUT — `store.macTail` hands this "…0170",
   which fits, and `flex: 0 0 auto` stops it shrinking below what it needs. The line now
   degrades by clipping the NAME, which the unit heading above repeats in full, instead of the
   identity, which nothing else on the card carries. An unnamed unit still gets all twelve
   characters, because there is no name to share the line with. */
.tile-unit .u-mac {
  flex: 0 0 auto;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  font-family: var(--mono);
  font-size: .92em;
  opacity: .8;
}
.unit-name .m-no { margin-right: 6px; font-size: .82em; vertical-align: 1px; }
/* Command Activity rows name the ClimateSync the zone belonged to. The MAC is the stable
   anchor; the M number beside it is how people actually refer to a box in the plant room. */
.act-unit { display: flex; align-items: center; gap: 6px; margin: 2px 0 1px; min-width: 0; }
.act-unit .u-mac { font-family: var(--mono); font-size: .78rem; color: var(--ink-3); }
.unit-meta .u-mac { font-family: var(--mono); }

/* ── the chart hover readout ───────────────────────────────────────────────────
   A crosshair on the nearest sample, and what that sample actually said. It reports the
   sample it snapped to, timestamp included, rather than interpolating a reading the unit
   never took. */
.chart-box { position: relative; touch-action: pan-y; }

.chart-hover { position: absolute; inset: 0; pointer-events: none; }

.ch-line {
  position: absolute;
  width: 1px;
  background: var(--ink-3);
  opacity: .55;
}
.ch-dot {
  position: absolute;
  width: 9px; height: 9px;
  margin: -4.5px 0 0 -4.5px;
  border-radius: 50%;
  background: var(--accent);
  box-shadow: 0 0 0 2px var(--panel);
}
.ch-tip {
  position: absolute;
  top: 6px;
  min-width: 132px;
  padding: 8px 10px;
  border: 1px solid var(--rule);
  border-radius: 10px;
  background: var(--panel);
  box-shadow: var(--shadow-lift);
  font-size: .74rem;
  line-height: 1.45;
  white-space: nowrap;
}
.ch-tip > b { display: block; font-size: .92rem; letter-spacing: -.01em; }
.ch-tip .ch-date { display: block; color: var(--ink-3); font-size: .68rem; margin-bottom: 5px; }
.ch-tip .ch-row {
  display: flex;
  align-items: center;
  gap: 6px;
  color: var(--ink-2);
}
.ch-tip .ch-row b { margin-left: auto; color: var(--ink); font-variant-numeric: tabular-nums; }
.ch-tip .ch-row i {
  width: 9px; height: 3px;
  border-radius: 2px;
  flex: 0 0 auto;
}
.ch-tip .ch-row i.room { background: var(--accent); }
.ch-tip .ch-row i.set  { background: var(--haux); }
.ch-tip .ch-row i.on   { background: var(--ok); height: 8px; border-radius: 50%; width: 8px; }
.ch-tip .ch-row i.off  { background: var(--ink-3); height: 8px; border-radius: 50%; width: 8px; opacity: .5; }

/* A group's section heading in the fleet list. Same shape as a unit heading — it folds the
   same way and sits in the same column — but tinted so a list of both reads as two kinds of
   thing rather than one long run. A group has no star and no unit page; its chevron opens the
   group editor instead. */
.grp-head { background: var(--panel-2); }
.grp-head .unit-name { font-weight: 700; }
/* ⚠ ON A GROUP HEADING THE NAME AND THE COUNT SHARE ONE LINE, AND THE ROW WAS ALREADY FULL.
   A unit heading stacks them (see `.unit-text`); a group heading does not, so the two compete,
   and by default flex shrinks both in proportion to how much they wanted. Measured at 375px:
   the caret, icon, name and count plus three 9px gaps came to exactly the 234px available —
   no slack at all. So when `.unit-pwr` beside it became a real 44px box and this row gave up
   6px, the name went one pixel short and "Mixed group" ended in an ellipsis. A regression of
   one pixel is still a regression, and it is the kind that is read as a bug.

   ⚠ 2, MEASURED, NOT PICKED. The count is regenerable and repeated on the screen below; the
   name identifies the group, so the count yields first — the same order `.tile-unit` uses for
   a name and a MAC. But the weight matters: at 3 and above the count is shrunk to ZERO on a
   long name and vanishes entirely, which is the `.u-mac` fault of collapsing a field to
   nothing. At 2 the name fits whole here, a 20-character name gets 122px instead of 113, and
   the count never reaches zero.

   ⚠ WHAT THIS DOES NOT FIX. A genuinely long group name — a group may be called up to sixty
   characters — still cannot fit beside a count on one line at 375px; at 37 characters both
   are clipped whatever the weight. The answer if that ever matters is the one the unit
   heading already took: stack the two onto separate lines. Not done here because it makes
   every group heading on Home 12px taller, which is a design change and not a defect fix.

   ⚠ AND THE SHRINK WEIGHT ALONE ONLY MOVED THE PIXEL. With it, the name fitted and the COUNT
   came up one short instead — "1 zone · 1 on" ending in an ellipsis, which is not a better
   answer, just a different loser. The row needs 235px and had 234, so the pixel had to be
   FOUND rather than allocated: this padding sits between the count and the power button, and
   the button already carries a 2px margin of its own, so removing it costs nothing anybody
   can see and buys four. Measured after, at 375px: name and count both complete, the gaps
   either side of the button still 2px, the heading still 44px tall. The weight below stays,
   because it is what decides the order when a name is genuinely too long for the row. */
.grp-head .unit-fold { padding-right: 0; }
.grp-head .unit-meta { flex-shrink: 2; }

/* The sentence behind a badge. A badge has room for a number and not for whose number it is,
   so anything that needs attributing gets a line here — set in the zone screen's repaint, so
   it appears and disappears with the condition rather than at screen entry. */
.zone-notes { margin: 8px 2px 0; display: grid; gap: 6px; }
.zone-note {
  margin: 0;
  font-size: .8rem;
  line-height: 1.45;
  color: var(--ink-2);
  border-left: 2px solid var(--rule);
  padding-left: 9px;
}

/* A unit somebody is physically working on. Warn rather than crit: nothing is broken, and
   the equipment is fine — it is us who must keep our hands off it for a day or two. */
.hold-box {
  background: var(--warn-soft);
  border-color: var(--warn);
}
.hold-title {
  display: flex;
  align-items: center;
  gap: 7px;
  font-weight: 700;
  color: var(--warn);
}

/* The search box, on every screen that lists zones. The icon sits inside the field rather
   than beside it so the control reads as one thing at 375px, where a separate icon column
   would cost width the placeholder needs. */
.search-wrap { position: relative; margin: 0 0 12px; }
.search-wrap .search-ico {
  position: absolute;
  left: 11px; top: 50%;
  transform: translateY(-50%);
  display: flex;
  color: var(--ink-3);
  pointer-events: none;
}
.search-wrap .search { padding-left: 34px; margin: 0; }

/* Shown only when the fetch bound is actually biting — never as decoration. It replaced a
   Previous/Next pair that was always there and described the wrong list. */
.reach-note {
  display: flex;
  align-items: flex-start;
  gap: 8px;
  margin-top: 14px;
  padding: 10px 12px;
  border-radius: 10px;
  background: var(--panel-2);
  color: var(--ink-2);
  font-size: .8rem;
  line-height: 1.45;
}
.reach-note svg { flex: 0 0 auto; margin-top: 2px; }

/* A sub-page's title. Same element as the scope switcher on a tab, but here it is a heading:
   no chevron promising a menu, no press, no focus stop. The class is set by paintHeader. */
.home-switch.plain { pointer-events: none; cursor: default; }
.home-switch.plain .home-name::after { display: none; }

/* The eye on a Names row: it puts a unit or a zone away, out of Home and the zone list.
   Same box as the pin beside a site, because it is the same kind of control — a second
   target on a row whose body opens something else.
   ⚠ THE LIT STATE IS THE PUT-AWAY ONE, NOT THE VISIBLE ONE. Forty rows lit to mean "shown"
   would say nothing at all; a handful lit to mean "put away" is the fact worth carrying a
   colour. The row's own subtitle says it in words as well, because a glyph on its own is
   not an explanation. */
/* ⚠ 44 TO A THUMB, 34 TO THE EYE — AND NOW IT IS ACTUALLY BOTH.
 *
 * The intent here was right and the implementation was the one that fails. It was a 34px box
 * with `padding: 5px; margin: -5px; box-sizing: content-box`, which does produce a 46px box —
 * and then the negative margin pulls that box OUTSIDE the 41px row it lives in, roughly 3px
 * above and 3px below, into the rows either side. Those rows each have a full-width button of
 * their own, painted after this one, so they win the overlap.
 *
 * ⚠ MEASURED IN THE RUNNING PAGE, 28 Aug 2026, on the Names screen: this control reported
 * **46x46 painted and 39px of hit height**, and the three pixels missing from each end did not
 * do nothing — they belonged to the NEIGHBOURING row's button. The old comment two lines up
 * said "a miss costs somebody the wrong unit put away". It was worse than a miss: a tap near
 * the top or bottom edge opened a different unit entirely.
 *
 * ⚠ SO THE BOX IS THE TARGET AND A PSEUDO-ELEMENT IS THE DRAWING, never the reverse. The
 * button is a plain 44x44 that paints nothing; `::before` draws the 34px pill inside it. No
 * negative margin, so it cannot leave its row, and the row below reserves the height for it.
 * `.scene-del` keeps the padding technique legitimately — it is absolutely positioned in a
 * card corner, so it has no siblings to collide with. The difference is the layout, not the
 * pixel count.
 */
.eye-btn {
  flex: 0 0 auto;
  position: relative;
  width: 44px; height: 44px;
  padding: 0; margin: 0;
  box-sizing: border-box;
  display: grid;
  place-items: center;
  border: 0;
  background: none;
  color: var(--ink-3);
}
.eye-btn::before {
  content: "";
  position: absolute;
  inset: 5px;                     /* 44 − 5 − 5 = the 34px pill that was always drawn */
  border: 1px solid var(--rule);
  border-radius: var(--r-pill);
  background: var(--panel);
}
.eye-btn svg { position: relative; }        /* over the pill, not under it */
.eye-btn.away { color: var(--warn); }
.eye-btn.away::before { border-color: var(--warn); background: var(--warn-soft); }
.eye-btn:active { transform: scale(.9); }
/* Dimmed, not struck through: it is still a real row you can open and rename. */
.pick-row.away .pick-main { opacity: .55; }

/* Zone Settings: the grid of individual modes and fan speeds a picker may offer.
   ⚠ EACH TILE IS A SWITCH, NOT A CHOICE. It says whether the option is OFFERED, so several
   are lit at once and none is "current" — which is why it carries role="switch" rather than
   looking like the mode picker it governs. Lighting the current mode here would read as
   "this zone is in HEAT", and this screen is nowhere near a zone. */
/* ⚠ A REAL GRID, DESPITE THE NAME THIS WAS `display: flex` UNTIL 27 Aug 2026, and the
   tiles were content-width. "Cool" came out 68px, "Aux heat" 74 and "High heat" 79, so every
   row ended somewhere different: at 375px the first row finished 19px short of the card's
   inner edge and the second 78px short. It read as a ragged block of buttons rather than a
   set of equal choices, and at a slightly narrower width it orphaned "High heat" alone on a
   row of its own. Equal columns that fill the row is what the reference app shows and what
   the words themselves mean - these are seven of one kind of thing, not seven different
   sizes of thing. `auto-fill` rather than a fixed count so a tablet gets four across
   without a second rule. */
.opt-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
  gap: 8px;
}
/* ⚠ THE TILES CARD AND THE SWITCH CARD ABOVE IT WERE FLUSH - 0px between them, measured,
   while every other pair of stacked cards on this screen sat 10px apart. Two 18px corners
   meeting made one card with a seam across it. 10px is not a new number: it is what
   `.inert-note` already gives the other stacked cards here. */
.opt-panel { margin-top: 10px; }

/* ── the temperature band on Zone Settings ──────────────────────────────────
   Two stepper rows, using the same `.dial-btns`/`.dial-step` control the floor-limit sheet
   uses — one idea of what a temperature stepper is, not a second one that can drift from it.
   ⚠ `.band-off` DIMS, IT DOES NOT DISABLE. The numbers stay editable while the limit is
   switched off, because setting the range up before turning it on is the obvious order to do
   it in, and because turning the switch off must not throw the numbers away. */
.band-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}
.band-row + .band-row { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--rule-soft); }
/* ⚠ ONE WORD, NO CAPTION UNDER IT, AND THAT IS A MEASUREMENT NOT A PREFERENCE. Each row first
   carried "nothing may be set below this" beneath the word. At 375px the stepper takes 236 of
   the 315px available, which left the label 67px — so a 28-character caption wrapped to THREE
   lines, the rows came out 87px and 98px tall, and two rows that do the same thing were
   visibly different heights. The sentence below the pair already says it better and says it
   once: "This zone can be set between 18 and 22 °C on its own page, on any group that includes
   it, and in any schedule that targets it." Nothing was lost by deleting the captions. */
.band-name { font-weight: 600; min-width: 0; }
.band-steps { margin: 0; flex: 0 0 auto; }
.band-off .band-row { opacity: .55; }

/* ⚠ THE PERMITTED SEGMENT OF THE ARC, drawn UNDER the fill and OVER the track, so the ring
   still shows where the zone really is while showing which part of it you may drag to. It is
   deliberately quiet — a second bright arc would compete with the fill for the eye, and the
   fill is the reading. Rendered only when a band is set. */
.dial-band {
  fill: none;
  stroke: var(--accent);
  stroke-width: 15;
  stroke-linecap: round;
  opacity: .26;
}
.opt {
  --opt: var(--ink-3);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 3px;
  /* ⚠ 0, not 68: a grid item defaults to min-width:auto and would push its own track wider
     than the others the moment a label got long - which is the ragged row this rule set out
     to remove, rebuilt one level down. The track minimum lives on the grid, above. */
  min-width: 0;
  padding: 9px 10px;
  border: 1px solid var(--rule);
  border-radius: var(--r-card);
  background: var(--panel);
  color: var(--ink-3);
  opacity: .55;
}
/* Same split as .dial-mode above: the border still carries the mode's own colour,
   only the word is mixed far enough toward --ink to be read on the panel. */
.opt.on { opacity: 1; border-color: var(--opt); color: color-mix(in srgb, var(--opt) 70%, var(--ink)); }
.opt:active { transform: scale(.94); }
.opt-ico { display: grid; place-items: center; }
.opt-txt { font-size: .74rem; font-weight: 600; letter-spacing: .01em; }
