  /* Three DOM states, not four. An absent cookie means "follow the OS", which
     is a real answer rather than a fourth mode to store. */
  :root[data-theme="light"] { color-scheme: light; }
  :root[data-theme="dark"]  { color-scheme: dark; }

  :root {
    /* ★ The highest value-per-byte declaration in the app.
       Without it, native controls render light chrome inside a dark page: the
       datetime-local picker panel, <select> option popups, both range inputs,
       scrollbars and autofill. `dark light` means dark-preferred, and honors
       the OS when the user has expressed no preference. */
    color-scheme: dark light;   /* no cookie: follow the OS, dark preferred */

    /* ---- surfaces ----
       Neutral, at the same luminance as the blue-tinted ramp they replace. */
    --bgColor-default: #111111;   /* the page */
    --bgColor-layer1:  #1a1a1a;   /* cards, header, the doc nav rail */
    --bgColor-field:   #282828;   /* inputs, badges, code, table headers, chips */
    --bgColor-inset:   #0d0d0d;   /* pre, .api-response - deeper than the page */
    --bgColor-zebra:   #171717;   /* table stripe and blockquote.
                                     ⚠️ NOT an inset: it is lighter than the
                                     page, not darker. Named for what it holds. */

    /* ---- text ---- */
    --fgColor-default:     #e6e6e6;
    --fgColor-muted:       #a2a2a2;  /* labels, secondary meta */
    --fgColor-subtle:      #828282;  /* timestamps, the quietest text.
                                        ⚠️ Was #6b7280, which measured 3.61:1
                                        on a card - below 4.5:1 and failing
                                        before this PR touched it. Its six
                                        consumers all sit on a card or a
                                        document body, so that is the pair
                                        that governs. */
    --fgColor-onEmphasis:  #fff;     /* on a filled control */
    --fgColor-strong:      #f4f4f4;  /* <strong> in rendered markdown */
    --fgColor-heading:     #d4d4d4;  /* md h3, em, blockquote body */
    --fgColor-tableHeader: #e7e7e7;
    --fgColor-navNested:   #848484;  /* second-level entries in the doc rail */

    /* ---- borders ---- */
    --borderColor-default:  #2b2b2b;
    --borderColor-muted:    #404040;
    --borderColor-emphasis: #666666;  /* ⚠️ Was #4b5563 = 2.31:1 on a card,
                                         under the 3:1 bar for a UI boundary.
                                         Also pre-existing. Its only consumer
                                         is the rotary pie's ring. */
    --borderColor-heading:  #313131;  /* the rule under an md h2 */

    /* ---- accent ----
       ★ Violet, because the mark is violet.

       The app's accent was #2563eb (~217deg) against the mark's ~259deg.
       Forty-five degrees apart is the worst adjacency available: near enough
       to read as a mismatch rather than a deliberate pair, and putting the
       mark top-left in a blue header was the one placement guaranteed to look
       wrong.

       ⚠️ The fill is the iris's hue and saturation exactly, at a slightly
       lower lightness - #8A5CF0 itself measures **4.31:1** under white, just
       under the 4.5:1 a button label needs. The plan estimated ~3.2:1 and
       named this the one place an estimate becomes an accessibility defect;
       it was neither, and only measuring found that. */
    --fgColor-accent:            #c4b0fb;  /* link text: the fill is too dark to read */
    --bgColor-accent-emphasis:   #8656ef;  /* the primary button; nothing else fills */
    --bgColor-accent-muted:      #241a3d;
    --bgColor-accent-subtle:     #1c1533;
    --bgColor-accent-inset:      #181228;
    --borderColor-accent-muted:  #8656ef55;

    /* ---- success ---- */
    --fgColor-success:            #86efac;
    --bgColor-success-emphasis:   #14532d;
    --bgColor-success-muted:      #10241a;
    --bgColor-success-inset:      #0f1f18;
    --borderColor-success:        #15803d;
    --borderColor-success-muted:  #14532d99;

    /* ---- attention ----
       ⚠️ Pushed oranger, away from the brand gold. #E8C25A is ~44deg and the
       old ramp sat at ~37-38deg: close enough that "brand" and "warning" would
       read as the same signal. These open the separation to 12-16deg. */
    --fgColor-attention:            #fca55a;   /* 28deg */
    --fgColor-attention-emphasis:   #d97706;   /* 32deg */
    --bgColor-attention-muted:      #2b1b0b;
    --bgColor-attention-inset:      #26180a;
    --borderColor-attention:        #b45309;   /* 26deg */
    --borderColor-attention-muted:  #b4530055;

    /* ---- danger ---- */
    --fgColor-danger:          #f87171;
    --bgColor-danger-muted:    #4a1d1d;
    --borderColor-danger:      #ef4444;

    /* ---- backlog statuses that wait (2026-09-25) ----
       ⭐ Two hues of their own, because "gray, but a different gray" did not
       read on the dark theme (owner). Cyan for a date that is coming, pink for
       the owner's call - both clear of accent purple, success green, attention
       orange and danger red. */
    /* ---- chart lines: the Okabe-Ito set (colorblind-safe), calm rather than pastel (2026-10-04, owner: "A-calm") ----
       ⭐ The blue is lifted on the dark ground (#0072B2 reads too dark there) and the vermilion a little, and the light values are darker
       versions of the same hues. ⚠️ Assigned by a room's stable order (`web/climate_chart`): sky blue, bluish green, orange, vermilion,
       reddish purple, then blue and yellow; two blues never sit next to each other. */
    --chart-0: #56b4e9;   /* sky blue */
    --chart-1: #009e73;   /* bluish green */
    --chart-2: #e69f00;   /* orange */
    --chart-3: #e36a2e;   /* vermilion */
    --chart-4: #cc79a7;   /* reddish purple */
    --chart-5: #4c97d3;   /* blue */
    --chart-6: #f0e442;   /* yellow */
    /* ⭐ The climate charts' ROOM lines (owner, 2026-10-10: muted and equal-weight, so the thermostat is the one thing that stands out): scoped to `.rc-svg[data-rooms]` in `_components.css`, so the appliance lanes
       and every other `--chart-N` user are unchanged. Assigned by a room's stable order, as `--chart-N` is. */
    --chart-room-0: #7f9fd1;   /* muted blue */
    --chart-room-1: #6fb3a6;   /* muted teal */
    --chart-room-2: #c9a96a;   /* muted sand */
    --chart-room-3: #c98b73;   /* muted terracotta */
    --chart-room-4: #c58fb8;   /* muted mauve */
    --chart-room-5: #a7a7a0;   /* muted gray */
    --chart-room-6: #8fb37a;   /* muted sage */
    --chart-room-7: #b39ddb;   /* muted lavender */
    --chart-outside: #9a9a96;  /* the outside line: dashed gray */
    --fgColor-scheduled:      #67e8f9;
    --borderColor-scheduled:  #0e7490;
    --fgColor-deferred:       #f9a8d4;
    --borderColor-deferred:   #be185d;
    /* Blocked (added the same day): waiting on somebody outside the house. */
    --fgColor-blocked:        #93c5fd;
    --borderColor-blocked:    #1d4ed8;

    /* ---- neutral ---- */
    --bgColor-neutral-emphasis: #404040;  /* a secondary/off button fill.
                                             ⚠️ Same value as --borderColor-muted
                                             today. Kept as two names because
                                             they are two jobs, and a later
                                             ramp will move them apart. */

    /* ---- brand ----
       The mark's own two colors, unchanged - the palette moved to meet them,
       which is the whole of PR1d.

       ⚠️ Gold never becomes a UI role. It is ~44deg, the same hue family the
       warning ramp used to occupy, so promoting it would make "brand" and
       "warning" indistinguishable. Still guarded.

       ★ Iris, by contrast, is now *deliberately* echoed by the accent - that
       is this PR's deliverable, and the guard was updated to say so rather
       than to keep forbidding it. */
    --brand-iris:  #8A5CF0;
    --brand-spark: #E8C25A;

    /* ---- shell metrics ----
       ★ 44px unconditionally, with no pointer query.
       An iPad with a trackpad reports `pointer: fine` and would get small
       rows; a Windows touchscreen laptop reports `fine` with
       `any-pointer: coarse`. Neither query answers the question, because the
       question is not answerable from the pointer. 44px everywhere deletes a
       media query, a second control scale, and an untestable criterion. */
    --control-height: 44px;
  /* ⭐ The second-level rail, sized to its own longest label.
     `infra/measure_rail.py` measures every label in `nav.VIEW_GROUPS` in the
     rail's own font and prints the width each needs. ⭐ It measures the
     BROWSER, at 4x device scale so the fractional width survives
     rasterization. ⛔ The PowerShell version is gone: it read the answer back
     with Chrome's `--dump-dom`, which has returned zero bytes since v152.
     ⚠️ It measures a
     `width: max-content` probe, NOT the rendered rows - those are
     `width: 100%`, so measuring one returns the rail's current width and the
     answer is circular.

     Widest is "Rooms & Zones" at 103px of text, 142px with the row's and the
     rail's padding. ⚠️ An earlier pass answered "API Reference" and would
     have sized the rail 12px short: it measured the pages that render, and
     `/ui/hue*` 500s locally without Hue credentials - so the widest label in
     the app was on the one page the harness could not load. The tool reads
     `nav.py` now, which has no such gap.

     ★ 10rem = 160px, so 18px clear of the observation rather than tuned to it
     - a label may grow by about two characters before it wraps. There is no
     cheaper check than re-running it: a character count does not rank width,
     and `test_guards_nav.py` records the one-minute experiment that proved it.
     What that guard does instead is pin the set of labels the measurement was
     taken over, so adding one tells you to measure again.
     ⚠️ A token because two rules must agree: the rail's own width, and the
     `body` padding that keeps `main`'s centerd measure where it was. */
  --rail-width: 10rem;
  /* Where a fixed rail starts, so it sits below the header rather than under
     it. ⛔ Without this the `top` was invalid, `position: fixed` fell back to
     `bottom: 0`, and the rail rendered at the FOOT of the page - caught by a
     screenshot, not by a test. */
  --head-height: 4.3rem;
    /* ★ Fluent's `durationFast`. Two rules used `.15s` and nothing named it,
       so a third would have been a guess rather than a match.

       ⚠️ Device timings are deliberately NOT here. `roku.html`'s SETTLE_MS and
       repeat cadence are JS constants and must stay so: a custom property read
       back through `getComputedStyle` is a token file used as a config store,
       and `prefers-reduced-motion` below overrides only transition and
       animation durations - it cannot reach a `setTimeout`, so a 0.01ms Roku
       settle would poll the TV before it answered. */
    --motion-fast: 150ms;
    /* ⚠️ **A duration is a decision, so it gets a name.** 2.6s is one
       drift of a wind streak across the site plan - slow enough to read as
       air moving rather than a loading spinner, fast enough that a glance
       catches direction. Far longer than --motion-fast because it is
       ambient, not a response to an action. */
    --motion-drift: 2.6s;
    --measure-prose: 68ch;
    /* ⭐⭐ **A reference document is scanned, not read linearly, and the cost of
       a short measure there is SCROLL.** `BACKLOG.md` renders ~14,000px tall at
       68ch; at 90ch that is roughly a quarter shorter. ⚠️ The 68ch column is
       right for sustained reading and this is not that - it is a document
       people search, skim and return to, where fewer screens beats ideal
       saccade length.
       ⛔ Still capped, and well short of the container: a 1360px line is ~170
       characters, where the eye loses its place returning to the next line.
       This is a different measure, not the absence of one. */
    --measure-doc: 90ch;
    --measure-wide: 900px;
    /* ⚠️ For pages whose content is an image rather than prose. Sized to the
       middle GOES tier plus the card's own padding, so the 1200px default
       renders at its true resolution on a desktop and the 2400px tier is
       genuinely worth choosing on a HiDPI screen. */
    --measure-image: 1360px;
    /* ⭐ The NIGHT versions of the text and accent colors (the wall's weather slides at 21:00-06:00; owner 2026-10-09: "dimmer text"): each is the day token mixed 40% toward the page
       background, so about 60% brightness in either theme. ⚠️ Computed where the day tokens resolve, so a theme change moves them too; `body.is-night` swaps them in on the three
       weather wall sections only, and an element marked `data-elevated` keeps the day tokens. */
    --fgColor-default-night: color-mix(in srgb, var(--fgColor-default) 60%, var(--bgColor-default));
    --fgColor-muted-night: color-mix(in srgb, var(--fgColor-muted) 60%, var(--bgColor-default));
    --fgColor-attention-night: color-mix(in srgb, var(--fgColor-attention) 60%, var(--bgColor-default));
    --fgColor-danger-night: color-mix(in srgb, var(--fgColor-danger) 60%, var(--bgColor-default));
    --fgColor-accent-night: color-mix(in srgb, var(--fgColor-accent) 60%, var(--bgColor-default));
  }

  /* ---- light mode ----

     ★ **An @supports block, not the declare-then-override idiom.**

     `--x: #1a1a1a; --x: light-dark(...)` does NOT degrade. Custom properties
     accept almost any token stream at parse time, so the `light-dark()`
     declaration wins in every engine and then fails at *substitution* -
     `background: var(--x)` becomes invalid-at-computed-value-time, which
     resolves to `unset`, which is transparent. Every surface in the app would
     go transparent in a browser without light-dark(). The @supports wrapper is
     the only correct form.

     ⚠️ Nothing but :root may set `color-scheme`. `light-dark()` resolves
     against the *consuming* element, so setting it on a subtree - the tempting
     candidate being `pre`/`.api-response` as permanently dark - flips every
     token beneath it.

     ★ In light mode the primary fill is the mark's light iris **exactly**
     (#4A1B8C, 11.50:1 under white). Only the dark variant needed derivation. */
  @supports (color: light-dark(red, red)) {
    :root {
    --bgColor-default: light-dark(#ffffff, #111111);
    --bgColor-layer1: light-dark(#f8f8f8, #1a1a1a);
    --bgColor-field: light-dark(#f1f1f1, #282828);
    --bgColor-inset: light-dark(#f3f3f3, #0d0d0d);
    --bgColor-zebra: light-dark(#f9f9f9, #171717);
    --fgColor-default: light-dark(#232323, #e6e6e6);
    --fgColor-muted: light-dark(#5f5f5f, #a2a2a2);
    --fgColor-subtle: light-dark(#6c6c6c, #828282);
    --fgColor-onEmphasis: light-dark(#ffffff, #fff);
    --fgColor-strong: light-dark(#111111, #f4f4f4);
    --fgColor-heading: light-dark(#363636, #d4d4d4);
    --fgColor-tableHeader: light-dark(#232323, #e7e7e7);
    --fgColor-navNested: light-dark(#5f5f5f, #848484);
    --borderColor-default: light-dark(#d6d6d6, #2b2b2b);
    --borderColor-muted: light-dark(#dddddd, #404040);
    --borderColor-emphasis: light-dark(#8e8e8e, #666666);
    --borderColor-heading: light-dark(#d6d6d6, #313131);
    --fgColor-accent: light-dark(#6b21a8, #c4b0fb);
    --bgColor-accent-emphasis: light-dark(#4A1B8C, #8656ef);
    --bgColor-accent-muted: light-dark(#ede4fb, #241a3d);
    --bgColor-accent-subtle: light-dark(#f4eefd, #1c1533);
    --bgColor-accent-inset: light-dark(#f8f4fe, #181228);
    --borderColor-accent-muted: light-dark(#4A1B8C55, #8656ef55);
    --fgColor-success: light-dark(#116329, #86efac);
    --bgColor-success-emphasis: light-dark(#1a7f37, #14532d);
    --bgColor-success-muted: light-dark(#dcfce7, #10241a);
    --bgColor-success-inset: light-dark(#effdf3, #0f1f18);
    --borderColor-success: light-dark(#15803d, #15803d);
    --borderColor-success-muted: light-dark(#14532d55, #14532d99);
    --fgColor-attention: light-dark(#7a3d06, #fca55a);
    --fgColor-attention-emphasis: light-dark(#9a4d00, #d97706);
    --bgColor-attention-muted: light-dark(#fdeed7, #2b1b0b);
    --bgColor-attention-inset: light-dark(#fff6e6, #26180a);
    --borderColor-attention: light-dark(#b45309, #b45309);
    --borderColor-attention-muted: light-dark(#b4530055, #b4530055);
    --fgColor-danger: light-dark(#b3261e, #f87171);
    --bgColor-danger-muted: light-dark(#fde2e1, #4a1d1d);
    --borderColor-danger: light-dark(#d1242f, #ef4444);
    --chart-0: light-dark(#2a8bc4, #56b4e9);
    --chart-1: light-dark(#00795a, #009e73);
    --chart-2: light-dark(#b87800, #e69f00);
    --chart-3: light-dark(#d55e00, #e36a2e);
    --chart-4: light-dark(#b0557f, #cc79a7);
    --chart-5: light-dark(#0072b2, #4c97d3);
    --chart-6: light-dark(#8a7d00, #f0e442);
    --chart-room-0: light-dark(#4a6fa8, #7f9fd1);
    --chart-room-1: light-dark(#3f8a7c, #6fb3a6);
    --chart-room-2: light-dark(#8f7332, #c9a96a);
    --chart-room-3: light-dark(#9a5a42, #c98b73);
    --chart-room-4: light-dark(#96508a, #c58fb8);
    --chart-room-5: light-dark(#6b6b64, #a7a7a0);
    --chart-room-6: light-dark(#5a8248, #8fb37a);
    --chart-room-7: light-dark(#7a5fb0, #b39ddb);
    --chart-outside: light-dark(#6b6b66, #9a9a96);
    --fgColor-scheduled: light-dark(#155e75, #67e8f9);
    --borderColor-scheduled: light-dark(#0e7490, #0e7490);
    --fgColor-deferred: light-dark(#9d174d, #f9a8d4);
    --borderColor-deferred: light-dark(#be185d, #be185d);
    --fgColor-blocked: light-dark(#1e40af, #93c5fd);
    --borderColor-blocked: light-dark(#1d4ed8, #1d4ed8);
    --bgColor-neutral-emphasis: light-dark(#6c6c6c, #404040);
    --brand-iris: light-dark(#4A1B8C, #8A5CF0);
    --brand-spark: light-dark(#B88A1E, #E8C25A);
    }
  }
    /* ★ **The largest visual defect this app had, and it was one missing rule.**

       Under the default `content-box`, `width: 100%` means 100% of the parent
       *plus* padding and border. So `input, select, textarea` below - 100%
       wide, .6rem horizontal padding, 1px border - rendered ~307px inside a
       286px card at 360px. Every form overflowed: `scheduler.html`'s six
       fields, every input and textarea on `api_reference.html`, and
       `login.html`. No guard could see it, because nothing was wrong with any
       single declaration.

       ⚠️ **A universal reset is not a free one-liner.** It retroactively
       changes every rule pairing an explicit size with padding or a border.
       Six existed; each was re-checked rather than discovered later:

         .dlg (:353)           latent overflow FIXED - see its comment
         .button-chip (:97)    4px smaller, and now actually the declared 2.2rem
         .api-response (:152)  scrolls ~19px sooner
         .nav-more-panel (:61) 10px narrower
         header nav a (:22)    no change - padding is horizontal, no border
         main / .ui-alert      carved back out, see below */
    *, *::before, *::after { box-sizing: border-box; }

    body { font-family: "Noto Sans", system-ui, sans-serif; margin: 0; background: var(--bgColor-default); color: var(--fgColor-default); }
    /* Reduced from 1rem 2rem: at 360px the old padding left 296px for the bar,
       and the mark plus four labels needs the 32px back. */
    header { padding: .75rem 1rem; background: var(--bgColor-layer1); border-bottom: 1px solid var(--borderColor-default); display: flex; justify-content: space-between; align-items: center; gap: .5rem; }
    header nav { display: flex; align-items: center; flex-wrap: wrap; gap: .25rem; }
    /* ★ 44px tap targets, from padding rather than a height, so a wrapped
       label still gets the full target. The old links were ~21px line boxes
       against a 44px minimum - the real defect, not the header's height. */
    /* ⛔ `.nav-more-toggle` is in this list, not styled separately. It was
       separate for about an hour and drifted immediately: `padding: 0` and no
       `min-height` left ☰ visibly smaller than the glyphs beside it and
       **below the 44px tap target** this file argues for elsewhere. A control
       that looks like a nav item must BE one. */
    header nav a, header nav summary, header nav .nav-more-toggle { color: var(--fgColor-default); text-decoration: none; font-weight: 500; display: inline-flex; align-items: center; min-height: var(--control-height); padding: 0 .6rem; border-radius: 6px; }
    header nav a:hover, header nav summary:hover,
    header nav .nav-more-toggle:hover { color: var(--fgColor-accent); }
    /* The active page, and the section it belongs to. aria-current is on the
       leaf only; .is-section marks the ancestor so the six pages inside More
       and the two secondary Hue views still show where they are. */
    /* ★ The current item and the current *section* look the same in the bar,
       and that is a fix rather than a shortcut.

       ⚠️ `aria-current="page"` is leaf-only, so on /ui/roku the bar's own item
       is the leaf and got the filled box, while on /ui/hue the leaf lives in
       the Hue view-nav and the bar item only got `.is-section` - text color,
       no box. Two visually different treatments for the same fact ("you are
       here"), which reads as a bug because it is one.

       The leaf/ancestor distinction is real and still expressed, in
       `aria-current` where a screen reader can use it. It is not a visual
       distinction the bar needs to make. */
    header nav a[aria-current="page"],
    header nav .is-section {
      color: var(--fgColor-accent);
      background: var(--bgColor-accent-muted);
    }
    /* ⭐ **The bar hides as you read and returns when you reach for it.**
       Pinning it outright was rejected because a wrapping nav becomes permanent
       screen loss - the whole basis of the "leave the wrapping" decision was
       that the chrome scrolls away. Auto-hiding keeps that true: the wrap costs
       one-time scroll distance, not permanent height.
       ⚠️ Viewport-agnostic on purpose. A desktop-only rule would be a fourth
       @media query and would have to fit the axis the other three partition;
       this helps most on a phone anyway, so it applies everywhere. */
    /* ⭐ **Pinned everywhere; hidden only where space is scarce.** On a desktop
       the bar stays put - there is vertical room to spare and a nav that is
       always there is simply better. On a phone it hides as you read on and
       returns as you reach back, which is what made pinning acceptable at all:
       §4.1's "leave the wrapping" decision rested on the chrome scrolling away,
       and an auto-hiding bar keeps that true.
       ★ Note this inverts the item as filed - "pin it on desktop" was the ask,
       and the answer is pin on desktop, hide on mobile. */
    .site-head { position: sticky; top: 0; z-index: 20;
                 background: var(--bgColor-default);
                 transition: transform var(--motion-fast) ease; }
    /* ⚠️ An anchor must not land underneath the pinned bar. */
    html { scroll-padding-top: 4rem; }
    @media (prefers-reduced-motion: reduce) {
      .site-head { transition: none; }
    }
    /* ★ Both are always in the DOM; the breakpoint decides which is seen.
       The word is the accessible name in either state, which is why nothing in
       the bar carries an `aria-label`. */
    /* ⚠️ `min-width` because the CHARACTERS are different widths, not the
       boxes. Measured at 390px: the emoji items came out 42px wide and ☰ came
       out 34px, with identical padding and height - so the bar looked unevenly
       spaced for a reason no amount of padding would fix. Reported from the
       live page. */
    .nav-glyph { display: none; font-size: 1.05rem; line-height: 1;
                 min-width: 1.45rem; text-align: center; }
    .nav-word { display: inline; }
    /* The settings disclosure. `.icon-control` in the markup carries the
       color and the tap target; these are the two things it does not cover. */
    .settings-menu > summary { list-style: none; }
    /* Last thing in the bar, so its panel hangs from the right edge rather than
       the left, which would run off. */
    .settings-menu .nav-more-panel { left: auto; right: 0; }
    /* ⭐⭐⭐ **The kiosk exit, and `fixed` is the whole requirement.** The
       owner's constraint was a gear in the top right that does NOT shift the
       rest of the page down a row - so it is out of flow entirely. Nothing
       about the page's own layout changes; `main` is untouched.

       ⚠️ **`position: fixed`, not `absolute`.** A wall dashboard scrolls, and
       an absolute gear would scroll away with the content - unreachable at the
       bottom of a long page, on a device with no address bar to fall back on,
       which is the exact dead end this exists to fix.

       ★ **Dimmed at rest and full strength on interaction.** A bright control
       on a display nobody touches is visual noise for the twenty-three hours a
       day it is not needed; at 0.35 it reads as a smudge until you look for it.
       ⛔ `:focus-within` is in the list deliberately - a keyboard user who
       cannot see the thing they have focused is the accessibility bug this
       kind of fade usually ships with. */
    .kiosk-gear {
      position: fixed; top: .5rem; right: .5rem; z-index: 30;
      opacity: .35; transition: opacity var(--motion-fast) ease-in-out;
    }
    .kiosk-gear:hover, .kiosk-gear:focus-within,
    .kiosk-gear:has(> details[open]) { opacity: 1; }
    /* ⚠️ Someone who asked for less motion still gets the opacity, just not
       the ramp - the control must never be permanently faint. */
    @media (prefers-reduced-motion: reduce) {
      .kiosk-gear { transition: none; }
    }
    /* ⛔ The panel would otherwise hang off the top-right corner and clip
       against the viewport edge on a tablet. */
    .kiosk-gear .nav-more-panel { top: 100%; left: auto; right: 0; }

    .settings-item {
      display: block; width: 100%; text-align: left;
      background: none; border: 0; color: var(--fgColor-default);
      font: inherit; padding: .4rem .6rem; border-radius: 6px;
      cursor: pointer; text-decoration: none; white-space: nowrap;
    }
    .settings-item:hover { background: var(--bgColor-neutral-muted); }
    /* ⭐ Log out's own distance from the kiosk control above it (owner, 2026-10-25: a thumb meant for one hit the other on the tablet). The margin is the point, the rule only shows it: .6rem each
       side puts most of a finger's width between the two. */
    .settings-sep { border: 0; border-top: 1px solid var(--borderColor-muted); margin: .6rem .3rem; }
    /* ★ A label, not a control: muted, not hoverable, and separated from the
       actions below it so it does not read as a third menu item. */
    .settings-greeting {
      margin: 0 0 .35rem; padding: .4rem .6rem;
      color: var(--fgColor-muted); font-size: .85rem; white-space: nowrap;
      border-bottom: 1px solid var(--borderColor-muted);
    }
    .nav-home { padding: 0 .5rem; }
    /* ⛔ `flex: none` because the nav wraps and this must not be squeezed by
       it. At 390px the glyph row filled the width and pushed the settings
       control clean off the right edge - present in the DOM, reachable by
       keyboard, and invisible. A control that exists and cannot be seen is
       worse than one that is absent, because nothing says it is missing. */
    .header-end { display: flex; align-items: center; gap: .25rem; flex: none; }
    /* ★ **One rule for both icon controls, because they are a pair.**
       The theme toggle and Log out sat on separate rules with the same values
       written twice - and they did not agree in practice. `.header-end` is
       `align-items: center`, so their BOXES were level; what was not level was
       the glyph inside. The toggle was a `<button>` centring its content by
       UA default while the link was an `inline-flex` centring a `<span>`, and
       the two glyphs resolve from different fonts with different metrics, so
       "centerd" meant two different things.

       ⚠️ Both are now flex containers that center in both axes, in a box
       of a known size. A residual hair of difference would be font ink rather
       than layout, and the fix for that is an inline SVG - which is what
       `_mark.html` already does, and is where to go next if this is not
       enough. */
    .icon-control { display: inline-flex; align-items: center; justify-content: center;
                    background: none; border: none; text-decoration: none;
                    color: var(--fgColor-muted);
                    font-size: 1.1rem; line-height: 1;
                    min-height: var(--control-height); min-width: var(--control-height);
                    padding: 0 .35rem; cursor: pointer; }
    .icon-control:hover { color: var(--fgColor-accent); background: none; }
    .nav-home .mark { display: block; }

    /* `More` as a disclosure: native aria-expanded, keyboard-operable, no JS. */
    /* ---- the grouped overflow ------------------------------------------
       ⛔⛔ **A button and a panel, not a `<details>`.** The promoted group has
       to lay out INLINE in the bar at desktop while the rest stays closed, and
       a closed `<details>` cannot have its children shown by CSS - measured
       twice in headless Chrome on 2026-09-01. `open` is an attribute, not a
       style.

       ★ One set of DOM nodes at both widths. The breakpoint changes LAYOUT,
       never position in the tree - a link rendered twice would give
       `test_shell` two `aria-current` candidates, and the server cannot know
       the viewport to render only one. */
    .nav-extra { display: contents; }
    .nav-grp { display: contents; }
    .nav-grp-label { display: none; }
    /* ⚠️ Only what a <button> needs to stop looking like one. Size, color,
       spacing and the tap target all come from the shared rule above, so ☰
       cannot drift away from the glyphs it sits beside. */
    .nav-more-toggle {
      background: none; border: 0; font: inherit; cursor: pointer; gap: .35rem;
    }
    /* ⭐⭐ **The second-level rail: a section's own views, down the left.**
       Sections live in the top bar; the longer within-section list lives here,
       which is the split every design system this project consulted recommends.
       Rendered ONCE, from `base.html` - fourteen page templates used to include
       it themselves, and rendering it in both places would put every view link
       in the DOM twice and give the page two `aria-current` candidates.
       ⚠️ That sentence originally quoted the Jinja include tag literally. This
       file IS a Jinja template, so the comment was parsed as a tag and every
       page 500'd - the CSS twin of the guard that bans Jinja expressions inside
       JavaScript comments.

       ⚠️ Fixed rather than a grid column, deliberately: `main` is a centerd
       `max-width` measure, and making the page a two-column grid would re-center
       every page's prose. A fixed rail plus `body` padding leaves it alone.

       ★ A section with no views renders an empty container, and `:empty` takes
       the padding away with it - Cameras, Events and the rest keep their full
       width. */
    @media (width >= 50rem) {
      /* ⛔⛔ **`.view-rail` is the section rail and NOTHING else may wear it.**
         This was `.view-nav`, and `.view-nav` was also the app's generic row of
         segmented chips - borrowed by Voices' speaking rate, the forecast's
         hourly interval, and GOES' zoom and detail controls. The moment this
         block turned the class into a fixed left rail, all four became
         sidebars: `/ui/voices` indented its whole page for a rate selector, and
         `/ui/weather-goes` stacked two controls on top of its real rail so the
         navigation was not visible at all.
         ★ So the presentation is `.chip-row`, which anything may use, and the
         LAYOUT is `.view-rail`, which only `_view_nav.html` emits.
         `test_guards_nav.py` asserts that stays true - a class that repositions
         the page and re-pads the body cannot be a shared look. */
      .view-rail {
        position: fixed; left: 0; top: var(--head-height); bottom: 0;
        width: var(--rail-width); overflow-y: auto; z-index: 5;
        display: flex; flex-direction: column; gap: .1rem; align-items: stretch;
        margin: 0; padding: 1.25rem .6rem;
        border-right: 1px solid var(--borderColor-muted);
        background: var(--bgColor-default);
      }
      /* ⛔⛔ **A rail is not a chip row turned sideways, and the first version
         was exactly that.** `_view_nav.html` renders `.btn`, which is a pill:
         its own border, its own background, and a width set by its label. Eight
         of those stacked gave ragged right edges, eight competing borders, and a
         column that read as loose buttons floating beside the page rather than
         as one list.

         ★ So in the rail the anchors become ROWS: full width, no border of
         their own, one quiet highlight for the current page. The rail's single
         right edge is the only line, which is what makes it read as a region
         rather than a pile.

         ⚠️ **The `body ` prefix is load-bearing, not tidiness.** The rail
         carries `.chip-row` too, so `.chip-row a:not([aria-current])` matches
         these anchors as well - and at (0,2,1) it is identical to this rule and
         appears LATER in the file, so it won on source order and every rail row
         kept its pill border. Media queries add no specificity; this is the
         second time that has caught me in one branch. */
      body .view-rail a,
      body .view-rail a:not([aria-current]) {
        justify-content: flex-start;
        width: 100%;
        min-height: 2.25rem;
        padding: .35rem .6rem;
        border: 0;
        border-radius: 6px;
        background: transparent;
        color: var(--fgColor-muted);
        font-size: .9rem;
        font-weight: 500;
        opacity: 1;
      }
      body .view-rail a:not([aria-current]):hover {
        background: var(--bgColor-field);
        color: var(--fgColor-default);
      }
      /* ★ The current row is filled edge to edge, so the eye finds it by
         SHAPE before it reads the word - the same job the pill was doing, done
         without giving every other row a border to compete with. */
      body .view-rail a[aria-current="page"] {
        background: var(--bgColor-accent-muted);
        color: var(--fgColor-accent);
      }
      /* ⭐⭐ **Headings over the runs** (owner, 2026-10-25, page map batch A): a section whose views name a `group` draws each run under a small label, `_view_nav.html`. The runs sit in the DOM current-run-first (so the phone can show it and fold the rest into `More`), and each carries its own `order` to put them back in the section's order here.
         ⛔ `details.rail-more` is flattened (`display: contents`, and its `::details-content` too) so the runs are flex items of the rail and `order` reaches them; the summary is only for the phone. The element is rendered `open`, so a browser that cannot flatten it still shows every link. */
      /* ⛔ **The rail scrolls; it never wraps into a second column.** `.chip-row` wraps, and a fixed-height column that wraps puts whatever does not fit BESIDE the rest, clipped: the headings made the rail taller than a short window and the last run slid out to the side (seen in a 760px screenshot). `body ` for the specificity `.chip-row` wins on otherwise (it is later in the file). A run is a full-width block that keeps its size. */
      body .view-rail { flex-wrap: nowrap; }
      .view-rail .rail-run { display: flex; flex-direction: column; gap: .1rem; align-self: stretch; width: 100%; flex: none; }
      .view-rail .rail-heading {
        margin: 1rem .6rem .2rem; font-size: .72rem; font-weight: 600; letter-spacing: .06em;
        text-transform: uppercase; color: var(--fgColor-muted);
      }
      .view-rail .rail-run--first .rail-heading { margin-top: 0; }
      .view-rail .rail-more { display: contents; }
      /* ⭐ The run's place in the section's own order, as a class (a template's inline `style=` is ratcheted, `test_template_ratchets`): a section has four runs at most, and `test_the_runs_fit_the_positions_the_css_names` says so. */
      .rail-pos-0 { order: 0; } .rail-pos-1 { order: 1; } .rail-pos-2 { order: 2; } .rail-pos-3 { order: 3; } .rail-pos-4 { order: 4; } .rail-pos-5 { order: 5; }
      .view-rail .rail-more > summary { display: none; }
      .view-rail .rail-more::details-content { display: contents; content-visibility: visible; }
      /* ★ `:has`, so the padding follows the rail's existence rather than being
         set on every page and unset again. A section with no views renders no
         element at all - see `_view_nav.html` for why `:empty` was the wrong
         tool. */
      body:has(.view-rail) { padding-left: var(--rail-width); }
      /* ⭐⭐ **The header spans the window; only `main` is indented.**
         The rail offset is `body` padding, so without this the HEADER was
         indented too: the bar began at x=160 and the top-left corner - the one
         place a logo belongs - sat empty above the rail. Two earlier attempts
         each fixed half of it. A reserved 12rem gutter put the mark in the
         corner but pushed `Lights` a whole rail-width right, leaving a gap
         nothing filled; pulling the mark back into the bar closed the gap and
         gave up the corner.

         ★ Both at once, because it is `body`'s padding either way: pull the
         header back out of it and give the width back. `body { margin: 0 }`,
         so `100%` is exactly the padding box's inner width and the sum is the
         window. The mark lands in the corner and `Lights` sits next to it. */
      body:has(.view-rail) .site-head {
        margin-left: calc(-1 * var(--rail-width));
        width: calc(100% + var(--rail-width));
      }
      /* ★ The mark's left edge on the rail's text edge, not on the header's
         padding. The rail insets its rows by `.6rem` of its own padding plus
         `.6rem` of the row's, so 1.2rem is where the column of words begins;
         the header's `1rem` plus this `.2rem` puts the logo on that same line.
         ⚠️ Two numbers that have to agree and cannot be derived from each
         other in CSS - if the rail's padding changes, this is the second edit. */
      .nav-home { padding-left: .2rem; }
      /* ★ **Larger without a taller bar.** Every nav item carries
         `min-height: var(--control-height)` - 44px - and that, not the mark, is
         what sets the header's height. Anything up to 44px is free; 40px is
         noticeably a logo and still inside the row, so the header stays 69px.
         Measured rather than assumed. */
      .nav-home .mark { width: 40px; height: 40px; }

      /* ⛔ Nothing overflows at desktop: all eleven sections are in the bar.
         ⚠️ `header nav` prefix required. The shared nav-item rule is
         `header nav .nav-more-toggle` at (0,2,1); a bare `.nav-more-toggle` is
         (0,1,0) and LOSES to it, media query or not - queries add no
         specificity. The button stayed visible and a screenshot caught it. */
      header nav .nav-more-toggle { display: none; }
    }

    /* ⭐⭐ **The phone rail: the current run, then `More`** (owner, 2026-10-25, page map batch A). Below 50rem the rail is wrapping chips, and a section of seventeen of them (Admin) is a wall: only the run the page is in shows, and the other runs fold into one `<details>` holding the SAME links (the DOM never has a second copy: one `aria-current`). The headings are hidden for the run on show (the chips are its own) and drawn over each run inside `More`. */
    @media (width < 50rem) {
      .view-rail .rail-run { display: contents; }
      .view-rail .rail-heading { display: none; }
      .view-rail .rail-more { flex: 1 0 100%; }
      .view-rail .rail-more > summary {
        display: inline-flex; align-items: center; cursor: pointer; list-style: none;
        min-height: var(--control-height); padding: 0 .9rem; border-radius: 6px;
        border: 1px solid var(--borderColor-emphasis); color: var(--fgColor-default);
      }
      .view-rail .rail-more > summary::-webkit-details-marker { display: none; }
      .view-rail .rail-more > summary::after { content: " ▾"; }
      .view-rail .rail-more[open] > summary::after { content: " ▴"; }
      .view-rail .rail-more[open] .rail-run { display: flex; flex-direction: row; flex-wrap: wrap; align-items: center; gap: .5rem; margin-top: .75rem; }
      .view-rail .rail-more[open] .rail-heading { display: block; flex: 1 0 100%; margin: 0; }
    }

    .nav-more { position: relative; }
    .nav-more summary { cursor: pointer; list-style: none; }
    .nav-more summary::-webkit-details-marker { display: none; }
    /* ⚠️ The literal glyph, not a CSS escape. `content: "\\25BE"` went through
       a Python string on the way here and `\\25` was read as an OCTAL escape, so a
       0x15 control character shipped and the page rendered "More␣BE". A raw
       character in a UTF-8 file cannot be misread by anything in between. */
    .nav-more summary::after { content: " ▾"; }
    .nav-more-panel { position: absolute; z-index: 20; top: 100%; left: 0; min-width: 11rem; display: flex; flex-direction: column; background: var(--bgColor-layer1); border: 1px solid var(--borderColor-default); border-radius: 10px; padding: .25rem; box-shadow: 0 6px 20px rgba(0,0,0,.45); }

    /* ⚠️ First focusable element on every page. Eleven nav links stood between
       a keyboard user and the content, on every page, before this. */
    .skip-link { position: absolute; left: -9999px; top: 0; background: var(--bgColor-accent-emphasis); color: var(--fgColor-onEmphasis); padding: .6rem 1rem; border-radius: 0 0 6px 0; z-index: 50; }
    .skip-link:focus { left: 0; }

    /* One focus treatment for everything.

       ⚠️ This said "a two-tone ring reads on any surface" and the ring is a
       single color - a sentence describing an implementation that was never
       written. Measured, the one color is fine: 8.49:1 dark and 7.10:1 light
       against the surface it lands on. If two-tone is ever wanted the reason is
       `forced-colors: active`, which overrides `outline-color` and drops
       `box-shadow`, not contrast.

       ⚠️ `border-radius: 4px` was also here and did not round the ring -
       `outline` already follows the element's radius. It rounded the ELEMENT,
       only while focused, so buttons snapped 6px to 4px and `.skip-link`'s
       asymmetric radius flattened at the moment it appeared. */
    :focus-visible { outline: 2px solid var(--fgColor-accent); outline-offset: 2px; }
    /* ⚠️ **Carved back out of the reset**, with `.ui-alert` below, because
       `--measure-wide` is a *measure* - it sits beside `--measure-prose: 68ch`
       and names the width of the content column, not of the box around it.
       Under border-box the 900px would include the 2rem of padding and every
       page would narrow by 32px, which is a look-and-feel change smuggled in
       by a form-overflow fix. The two rules carry the same horizontal padding
       so they stay aligned either way; this keeps them aligned *and* unmoved. */
    main { box-sizing: content-box; padding: 2rem 1rem; max-width: var(--measure-wide); margin: 0 auto; }
      /* ★ **A wider measure for a page whose content is a picture.**
         `--measure-wide` is a *measure* - it names a comfortable reading
         column, and 900px is right for prose and tables. It is the wrong
         constraint for a 2400px satellite frame, which was being displayed at
         a third of the pixels it cost to fetch.
         ⚠️ Opt-in per page rather than a raise for everyone: widening every
         page would relayout eleven of them to fix one. */
      main.main--wide { max-width: var(--measure-image); }
      /* ⭐⭐ **The opposite opt-in, for a page whose content is ALL prose and
         narrow rows.** `/ui/usage` is sixteen rows and a dozen paragraphs, and
         every one of them caps at `--measure-prose`. In a 900px `main` that
         left roughly a third of every card empty on the right - which is the
         other half of "the column widths seem inconsistent", measured
         2026-09-03: the content stopped at 68ch while the card kept going.

         ★ `--measure-prose` plus the card's own padding, so the card hugs the
         measure instead of the measure floating inside the card. The number is
         composed from the two tokens rather than picked, which is the same rule
         `--measure-wide` records about itself. */
      main.main--prose { max-width: calc(var(--measure-prose) + 2.5rem); }
      /* ⭐ **For a page whose centerpiece is a wide table** - `/ui/backlog`'s
         thirteen-column open-work table (owner, 2026-09-25: "cut off on the
         right even at very high resolutions", because it shared the 1360px
         column with the contents rail). The table gets the screen; the prose
         around it is held back to the image measure in `_doc_style.html`, so
         lines stay readable. */
      main.main--table { max-width: min(2200px, 100%); }
    h1 { margin-top: 0; }
    .card { background: var(--bgColor-layer1); border: 1px solid var(--borderColor-default); border-radius: 10px; padding: 1.25rem; margin-bottom: 1rem; }
    /* ⚠️ The comment here used to say "the button inside is the control",
       which was wrong in the file that most needed to be right: the button
       carried `tabindex="-1"`, so the ANCHOR took focus. The view-nav no
       longer wraps buttons in links at all - each view is one `.btn` anchor -
       so the claim is gone along with the markup it described.

       The `.chip-row a[aria-current="page"] button` rule went with it. It was
       already a no-op: only NON-current items ever carried a class, so the
       accent it set was the default a button already had. */
    .chip-row { flex-wrap: wrap; margin-bottom: 1rem; }
    .chip-row a { text-decoration: none; }
    /* ⛔ **`.off` meant two opposite things on one screen.** `_view_nav.html`
       gave every inactive tab `class="btn off"`, and `.off` is this app's
       "not the primary action" - the same class, and so the same solid gray
       fill, that a row's `Continue` / `Play` / `Tune` buttons carry.

       So on `/ui/streaming` at 390px, seven gray nav pills sat above about
       thirty gray row buttons, identical in weight: the reader cannot tell
       "a page you are not on" from "press this to start something on a
       television".

       ⚠️ §4.2 of the design review called this a contrast problem and its own
       confirmation pass disproved that - `.btn.off` measures 5.25:1 light and
       10.37:1 dark. Legibility was never the issue. The strongest visual
       signal on the page was doing two contradictory jobs.

       ★ **Keyed on `aria-current`, not on a class**, so the style cannot drift
       from the state a screen reader is given - the same reason `.target-row`
       styles on `aria-pressed`. An inactive tab is now an outline: quieter than
       any button, and unmistakably not one. The current tab keeps `.btn`'s
       accent fill, so "where am I" stays the loudest thing in the row.

       ⚠️ `--borderColor-emphasis`, not `-default`: this is a control's visual
       boundary and needs 3:1. Slice 5 measured `-emphasis` at 3.08:1 light and
       3.03:1 dark on a card, and `-muted` at 1.28:1 - which is what made the
       "Play on" chips vanish. Same lesson, same token.

       ★ **This settles a three-way vocabulary rather than adding a fourth
       style**, which §6.4 was right to worry about. An outline tab looks like
       the "Play on" chips, and it SHOULD: both mean the same thing.

       | treatment | means |
       |---|---|
       | accent fill | you are here / this is selected |
       | outline | selectable, not currently selected |
       | solid gray (`.off`) | an action you can take |

       ⚠️ That is the collision that mattered being removed, not moved: `.off`
       previously meant *both* "not selected" and "an available action". Outline
       and the chips share one meaning; they never shared two. */
    .chip-row a:not([aria-current]) {
      background: transparent;
      color: var(--fgColor-default);
      border: 1px solid var(--borderColor-emphasis);
    }
    .chip-row a:not([aria-current]):hover { background: var(--bgColor-field); opacity: 1; }
    /* ⛔ **D8: the boldest element in a Hue row was the value nobody scans
       for.** At `max-width: 220px` and full brand accent, the brightness bar was
       wider and higher-contrast than the light's own NAME, twenty-seven times
       down the page.

       ★ Narrower, so the name is the widest thing in its row again. The accent
       stays: on a LIT light the filled track is the honest signal, and slice 5
       already mutes it when the light is off. This is about size, not color -
       the bar still has to be draggable with a thumb, which is why it is 9rem
       and not smaller. */
    input[type="range"].brightness { flex: 1; max-width: 9rem; accent-color: var(--bgColor-accent-emphasis); }
    /* ⛔ **A light that is OFF showed a fully saturated brand-colored bar** -
       the strongest "on" signal on the page, directly contradicting its own
       toggle. The remembered level is legitimate information and stays; what
       goes is the claim that it is currently lit. */
    input[type="range"].brightness[data-lit="no"] { accent-color: var(--borderColor-emphasis); }
    .brightness-value { font-size: .8rem; color: var(--fgColor-muted); min-width: 3ch; text-align: right; }
    button { background: var(--bgColor-accent-emphasis); color: var(--fgColor-onEmphasis); border: none; border-radius: 6px; padding: .4rem .9rem; cursor: pointer; font-size: .9rem; }
    button:hover { opacity: .85; }
    /* ⭐ A disabled control looks disabled (2026-09-27): the Devices
       page grays out a strip whose firmware refuses renames, and until then
       nothing here styled `:disabled` at all. */
    button:disabled, button:disabled:hover, input:disabled { opacity: .45; cursor: not-allowed; }
    /* ⛔ **A badge must never break across two lines**, and one did.
       Captured at 390px on /ui/hue-scenes: the scene "not too blue bright"
       wrapped, and its own `Bathroom · room` badge split into `Bathroom ·` on
       one line and `room` on the next - two fragments, each with its own
       background and padding, reading as two badges that are not.

       ★ `white-space: nowrap` is the whole fix. A badge is a single token by
       definition: it is a label for one fact, and half a label is not a
       shorter label, it is a different one. If a badge is too long to fit, the
       badge is wrong - wrapping it is not the remedy. */
    .badge { font-size: .75rem; padding: .15rem .5rem; border-radius: 4px; background: var(--bgColor-field); color: var(--fgColor-muted); margin-left: .5rem; white-space: nowrap; }
    /* The model badge is its own line so a long device name cannot push it
       onto a second one on some cards and not others. */
    .model-line { margin: .1rem 0 .4rem; }
    .model-line .badge { margin-left: 0; }
    /* ⛔ **An EMPHASIS background needs an onEmphasis foreground.** This paired
       `--bgColor-success-emphasis` with `--fgColor-success` - two colors from
       the same family, both intended to sit against the PAGE rather than
       against each other. Measured 2026-08-31 in light theme: #116329 on
       #1a7f37 = **1.46:1**, against the 4.5 that normal text needs. Dark theme
       happened to pass at 6.49:1, which is why it only appeared in the light
       captures. With `--fgColor-onEmphasis` it is 5.08:1 light and 9.11:1 dark. */
    .badge.on { background: var(--bgColor-success-emphasis); color: var(--fgColor-onEmphasis); }
    /* ⚠️ Icon-only now, to give the six nav labels their horizontal space
       back. It shares `.icon-control` with the theme toggle beside it, so the
       two read as a pair by construction rather than by two rules agreeing -
       and the padding is what keeps a 1.1rem glyph a `--control-height` tap
       target. */
    /* ⚠️ `a.logout` and `.theme-toggle` are both `.icon-control` now; the
       rules that used to live here are gone rather than duplicated. */
    /* ⚠️ `word-break` matched from `.md code` (`_doc_style.html:153`), which
       has had it all along. Without it a long payload or cron expression in a
       `<code>` forced its whole ROW to scroll horizontally - and the scheduler
       renders exactly that, per task. */
    code { background: var(--bgColor-field); padding: .1rem .35rem; border-radius: 4px;
           font-size: .9em; word-break: break-word; }
    .form-row { margin-bottom: .6rem; }
    .form-row label { display: block; font-size: .85rem; color: var(--fgColor-muted); margin-bottom: .25rem; }
    input, select, textarea { background: var(--bgColor-field); color: var(--fgColor-default); border: 1px solid var(--borderColor-muted); border-radius: 6px; padding: .4rem .6rem; font-size: .9rem; width: 100%; max-width: 500px; min-height: var(--control-height); }
    /* ⛔ **Three controls are not text fields, and the rule above was making
       all three into one.** `input, select, textarea` gives every input a field
       background, a 1px border, `.4rem .6rem` of padding, a 6px radius,
       `width: 100%` and a 44px minimum height. Measured on a swatch page built
       from this stylesheet: a checkbox rendered as a **~50px square on its own
       line**, a radio as a ~50px circle, and a range as a bordered 44px box
       with a track drawn inside - which is why an unlit group at 0% on
       `/ui/hue-groups` read as a filled bar rather than an empty one.

       ⛔ **It was patched per-container three times before it was fixed
       once.** `.library-list input[type="checkbox"]` and `.voice-chip input`
       each escaped it for one page, so the next page that used a checkbox got
       the defect back. That is why this rule targets the ELEMENT: a control is
       not a text field wherever it appears.

       ⚠️ **`min-height: 0` for checkbox and radio, KEPT for range.** A
       checkbox takes its touch target from the `<label>` around it, which is
       how both of the containers above already work; a slider IS its own
       target, and one you cannot get a thumb on is a worse defect than a
       bordered one.

       ⚠️ The range track is otherwise left to `accent-color` deliberately -
       hand-styling `::-webkit-slider-runnable-track` means also hand-styling
       the thumb, trading a cosmetic bug for a cross-browser one. */
    input[type="checkbox"], input[type="radio"] { background: none; border: 0;
        padding: 0; border-radius: 0; width: auto; min-height: 0; }
    input[type="range"] { background: none; border: 0; padding: 0;
        border-radius: 0; width: auto; }
    /* ⛔ **At the ELEMENT, not on `.color-pick`, and the guard was right to
       insist.** A class escape is one row away from being forgotten: a color
       input rendered without that class would silently inherit text-field
       geometry from `input, select, textarea` and appear as an empty text box.
       ★ `test_guards_control_box` holds container-only escapes at zero, so this
       is an invariant rather than a preference. The swatch's own chrome is the
       browser's - hand-styling it means hand-styling the picker button too. */
    input[type="color"] { background: none; border: 0; padding: 0;
        border-radius: 0; width: auto; min-height: 0; }
    /* ⭐ **A file input is a BUTTON plus a filename, not a text box.** Left
       to inherit `input, select, textarea`, it draws a field-shaped well with
       the browser's own "Choose file" button floating inside it - two nested
       boxes, one of which is ours and wrong. ⛔ At the element for the reason
       the color swatch records: a class escape is one row away from being
       forgotten. ★ The button inside is the browser's chrome and stays that
       way; hand-styling `::file-selector-button` means owning its focus and
       active states in three engines. */
    input[type="file"] { background: none; border: 0; padding: 0;
        border-radius: 0; width: auto; min-height: 0; }
    /* ★ **A label and its field are one thing, so they wrap as one.**
       `/ui/livetv`'s guide row put five children in a `.row` - a button, two
       labels and two inputs - and at 390px `flex-wrap` broke it wherever it
       ran out of width, stacking "Re-scan / channels / older / than" four words
       tall with its input somewhere else. Grouping is the whole fix: a pair
       cannot come apart if it is one flex item. */
    /* ⛔ **The first version of this made the row WORSE, and the recapture is
       the only reason that is known.** Side by side, `.field-pair` held the
       label beside its input - so "Re-scan channels older than" plus a 5rem
       field was wider than a 390px card, and because `.row` is `nowrap` (only
       `.row.wrap` wraps) the pair could neither break nor shrink. `15m` ended
       up outside the card entirely. The previous defect was ugly; this one lost
       a control off the edge of the screen.

       ★ **Column, not row: the label goes ABOVE its input.** A pair is then
       only as wide as the wider of the two, which is the field - so three pairs
       fit a phone, and the label may wrap to two lines without dragging its
       input anywhere. This is the ordinary shape for a narrow form and the
       side-by-side version was the deviation. */
    .field-pair { display: inline-flex; flex-direction: column;
                  align-items: flex-start; gap: .2rem; }
    .field-pair > label { white-space: normal; }
    .field-pair > input { width: auto; }
    /* ⛔ **`--fgColor-accent` says `link text` in its own definition, and
       until now no rule consumed it.** Every anchor outside a nav, a tile or a
       `.btn` fell through to the browser default - so `/ui/plex`'s server list
       and `/ui/weather-goes`'s "Now tab's radar" rendered in Chrome's link
       blue on a page where every other interactive thing is purple.

       ⚠️ **This is a consistency fix, NOT an accessibility one, and the
       difference is worth stating because it looked like the opposite.**
       Measured rather than assumed: Chrome's dark-scheme default `#9E9EFF` on
       `--bgColor-default` is **8.0:1**, which passes AA comfortably. The token
       is 9.9:1 dark and 8.7:1 light. Both pass; only one belongs to this app.

       ★ Scoped to skip what already decides its own color, so the nav,
       the tiles and every `.btn`-shaped anchor are untouched. */
    main a:not([class]) { color: var(--fgColor-accent); }
    main a:not([class]):hover { text-decoration-thickness: 2px; }
    textarea { font-family: monospace; display: block; margin-top: .25rem; }
    /* ⚠️ `#create-error` was here - the file's ONLY id selector, and a
       near-copy of `.field-error` below (same color, .85rem against .8rem).
       It was also the one entry in the selector audit's allow-list that had no
       reason to be there. The element carries `class="field-error"` now, so
       the form's five field errors and its submit error are one rule. */
    .switch-visual { display: flex; gap: .9rem; margin: .5rem 0; flex-wrap: wrap; }
    .chip-group { display: flex; flex-direction: column; align-items: center; gap: .25rem; }
    .button-chip { width: 2.2rem; height: 2.2rem; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: .9rem; font-weight: 600; border: 2px solid var(--borderColor-muted); background: var(--bgColor-field); color: var(--fgColor-muted); cursor: default; }
    .button-chip.pressed { background: var(--bgColor-accent-muted); color: var(--fgColor-accent); border-color: var(--bgColor-accent-emphasis); }
    .button-chip.long-pressed { background: var(--bgColor-danger-muted); color: var(--fgColor-danger); border-color: var(--borderColor-danger); }
    .chip-label { font-size: .7rem; color: var(--fgColor-muted); }
    .chip-time { font-size: .65rem; color: var(--fgColor-subtle); }
    /* ---- PR7: the method badge, and the app's last color literals ----
       ⚠️ Six hexes lived in a `METHOD_COLORS` map in api_reference.html, plus
       a seventh (`#374151`) hard-coded as the fallback at the call site. The
       unqualified rule below IS the fallback now, so an HTTP method nobody
       anticipated still gets a readable badge instead of whatever the last
       literal happened to be. */
    .method-badge { display: inline-block; padding: .1rem .5rem; border-radius: 4px;
                    background: var(--bgColor-neutral-emphasis);
                    color: var(--fgColor-onEmphasis); font-size: .75rem;
                    font-weight: 700; vertical-align: middle; }
    .method-badge[data-method="get"]    { background: var(--bgColor-accent-emphasis); }
    .method-badge[data-method="post"]   { background: var(--bgColor-success-emphasis); }
    /* PUT and PATCH share a tone deliberately: both are "modify what is there",
       and giving them separate colors would imply a distinction the API does
       not make. */
    .method-badge[data-method="put"],
    .method-badge[data-method="patch"]  { background: var(--borderColor-attention); }
    .method-badge[data-method="delete"] { background: var(--borderColor-danger); }

    /* Collapsed endpoints. The app exposes ~40 operations and the page rendered
       every parameter input and body textarea for all of them.

       ⚠️ Denser than the default row. Forty collapsed rows at `--control-height`
       plus card padding was still more scrolling than a reference page should
       need, and a summary row is a link to read, not a control to hit with a
       thumb - the 44px floor is for things you tap to *act*. The tap target is
       still the full width of the row. */
    .endpoint { padding: 0; }
    /* ⚠️ `list-style: none` and an explicit chevron.
       `display: flex` on a <summary> suppresses the disclosure marker in
       Chrome, so styling `::marker` did nothing and the rows had NO affordance
       at all - forty things that expand, with nothing saying so. Caught by
       rendering the page, not by reading the CSS. */
    .endpoint-summary { cursor: pointer; display: flex; align-items: center;
                        gap: .4rem; padding: .15rem 0; min-height: 2.1rem;
                        list-style: none; }
    .endpoint-summary::-webkit-details-marker { display: none; }
    .endpoint-summary::after { content: "▸"; color: var(--fgColor-subtle);
                               margin-left: auto; font-size: .8rem; }
    .endpoint[open] > .endpoint-summary::after { content: "▾"; }
    .endpoint[open] .endpoint-summary { margin-bottom: .3rem; }
    /* ⚠️ Only inside an open endpoint. `.rows > li` gives every row a bottom
       border, which at forty rows is forty rules competing with the text. */
    .api-doc .rows > li { border-bottom: none; padding: 0; }
    .api-doc .rows > li:has(.endpoint[open]) { border-bottom: 1px solid var(--borderColor-default); }
    /* Section headings on a reference page are labels, not titles: `tasks (4)`
       does not need to be the same size as the page's own <h1>. */
    .api-doc h2 { font-size: 1rem; letter-spacing: .01em; margin: 0 0 .4rem; }
    .api-doc .card { padding: .8rem 1rem; margin-bottom: .6rem; }
    .api-doc .form-row { margin-bottom: .45rem; }
    /* ⭐ **One proposed call: the call, why, and where to go and send it.**
       Deliberately NOT a card each - a card per call would make three
       suggestions look like three separate things to decide about, when they
       are one answer. ⚠️ The border-left rather than a box, because the
       reading order is down the list and a box invites the eye to stop. */
    .assistant-call { border-left: 3px solid var(--borderColor-accent-muted, var(--borderColor-default));
                      padding: .1rem 0 .1rem .7rem; margin: .6rem 0; }
    .assistant-call > a { font-size: .85rem; }
    /* ⚠️ The call itself is monospace and scrolls in its own box. A path with a
       query string is the one thing on this page that can outgrow the column,
       and the page body must never scroll sideways. */
    /* ⭐ The page comes before the call and reads as the answer, so it gets
       the emphasis and the call below it is the footnote. */
    .assistant-page { margin: 0 0 .8rem; }
    .assistant-page > p { margin: 0 0 .3rem; }
    /* ⚠️ A turn in the log, not a card: forty of them as cards would read as
       forty things to decide about rather than one list to scan. */
    .assistant-turn { padding: .4rem 0; }
    .assistant-turn > p { margin: 0 0 .2rem; }
    .assistant-label { font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
                       font-size: .85rem; background: var(--bgColor-field);
                       border-radius: 4px; padding: .35rem .5rem; margin: 0 0 .3rem;
                       overflow-x: auto; white-space: pre; }
    /* ⭐ **Collapsed by default, and that is the whole safety argument in
       CSS.** The box can send a DELETE to any route in the app. Open, it would
       be the first thing on the page and an inviting empty field; closed, it is
       a labeled affordance somebody chooses. ⚠️ `--control-height` on the
       summary because a `<summary>` is a control - the same 44px every other
       tappable thing on this page gets. */
    .api-freeform > summary { cursor: pointer; display: flex; align-items: center;
                              gap: .4rem; min-height: var(--control-height);
                              font-weight: 600; list-style: none; }
    .api-freeform > summary::-webkit-details-marker { display: none; }
    .api-freeform > summary::after { content: "▸"; color: var(--fgColor-subtle);
                                     margin-left: auto; font-size: .8rem; }
    .api-freeform[open] > summary::after { content: "▾"; }
    .api-freeform[open] > summary { margin-bottom: .5rem; }
    /* ⚠️ A path is read character by character when it does not work, so it
       gets the same monospace the responses do. */
    .api-freeform input, .api-freeform textarea { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
    .api-freeform textarea { width: 100%; resize: vertical; }
    .api-response { background: var(--bgColor-inset); border: 1px solid var(--borderColor-default); border-radius: 6px; padding: .6rem; font-size: .8rem; white-space: pre-wrap; word-break: break-word; max-height: 300px; overflow-y: auto; margin-top: .5rem; min-height: 1.2rem; }
    /* ⛔ **A ~13px target, twenty-seven times, on the most-used page in the
       app.** `.rename-btn` sets `font-size: .8rem; padding: 0 .2rem` and no
       `min-width` or `min-height` at all, so the tap target is the glyph. The
       guideline is 44px, which this app already names as `--control-height`.

       ★ **The hit area grows; the glyph does not.** A 44px visible box beside
       every light's name would put a thumb-sized gap in twenty-seven rows to
       make an editing affordance louder than the light it renames. An absolutely
       positioned `::after` takes the area out of flow instead, so the row's
       layout is untouched - and the row is already 44px tall because of its
       toggle, so nothing grows.

       ⚠️ The overlay extends about 15px over the end of the name text. That is
       deliberate and harmless: the name is not itself a control, so the only
       thing a tap there could have hit is nothing. */
    /* ⚠️ **D9: separated from the name it renames.** Inside `.row-name`'s inline
       flow with no gap, the glyph read as the last character of the light's name
       - every light appeared to be called "Bath ambience (upstairs) ✏️". A
       control adjacent to a label needs to be adjacent, not attached.
       ★ `margin-left` rather than a wrapper: the 44px hit area from slice 11 is
       an absolutely positioned `::after`, and a new box would move it. */
    .rename-btn { position: relative; background: none; border: none; color: var(--fgColor-subtle); cursor: pointer; font-size: .8rem; padding: 0 .2rem; margin-left: .35rem; }
    .rename-btn::after {
      content: "";
      position: absolute;
      top: 50%;
      left: 50%;
      width: var(--control-height);
      height: var(--control-height);
      transform: translate(-50%, -50%);
    }
    .rename-btn:hover { color: var(--fgColor-accent); opacity: 1; }
    .badge.alert-badge { background: var(--bgColor-danger-muted); color: var(--fgColor-danger); }

    /* ---- where an event was routed ----
       ⭐ A THIRD visual weight, on purpose. §3.4 of `events.html` records what
       happens when everything is a filled `.badge`: ~200 of them per page made
       `alert-badge` read as just another chip. So these are outlines, never
       fills - the alert badge keeps the only filled container on the page.
       ⚠️ Weight, not hue, is what separates them. Giving `home` and `dev` their
       own colors would re-create the competition in a different dimension. */
    .event-routing { display: flex; flex-wrap: wrap; gap: .3rem; margin-top: .3rem; }
    .badge.route-badge {
      background: transparent;
      border: 1px solid var(--borderColor-default);
      color: var(--fgColor-subtle);
      font-size: .6rem;
      letter-spacing: .04em;
      text-transform: uppercase;
      padding: .05rem .35rem;
    }
    /* The river is on every row, so it is the quietest thing on it. */
    .badge.route-badge.route-river { opacity: .6; }
    /* ⚠️ The one routing chip that reports an ABSENCE - nobody but the river
       was told - so it carries the warning color rather than the neutral one. */
    .badge.route-badge.route-only {
      border-color: var(--borderColor-attention-emphasis, var(--fgColor-attention));
      color: var(--fgColor-attention);
    }
    .event-row .row-head { align-items: flex-start; gap: 1rem; }
    /* The rest of a multi-line event. `pre-line` keeps the author's line
       breaks without turning a long line into a horizontal scroll, and the
       measure stops a wide screen from producing 200-character lines. */
    /* ⚠️ The clamp is a LAYOUT bound, not a content bound - and the two are
       different jobs, which is why both exist.

       `app/core/text.py` owns the content budget: 3 sentences, 400 characters,
       applied in Python to new events and to legacy ones on the way out. Six
       lines at `--measure-prose` (68ch) is 408 characters, so at the intended
       reading width this clamp hides NOTHING. It earns its place only at
       narrow widths, where 400 characters wraps to nine lines and a feed row
       would otherwise be a page. */
    .event-detail { font-size: .85rem; color: var(--fgColor-muted); white-space: pre-line;
                    max-width: var(--measure-prose); margin: .1rem 0 .3rem;
                    display: -webkit-box; -webkit-box-orient: vertical;
                    -webkit-line-clamp: 6; line-clamp: 6; overflow: hidden; }
    /* ⚠️ .chip-time is .65rem - fine to read, far too small to hit. The padding
       is what makes this a tappable target rather than a decorative link. */
    .event-link { display: inline-block; padding: .35rem .2rem; }
    /* ======================================================================
       The primitives. See docs/frontend-primitives.md.

       ★ **PR9 deleted the duplicates.** From PR2 to PR8 this file carried both
       vocabularies at once - `.light-row` for unconverted pages and
       `.rows > li` for converted ones - which is an alias layer, and the plan
       was explicit that naming it honestly and counting it was the only way to
       keep it temporary. The count is now zero: `.light-row`,
       `.light-header`, `.light-controls`, `.scheduled-meta` and
       `.scheduled-meta.connectivity-issue` are gone, along with `.stack`,
       a utility PR2 introduced and no page ever used.
       ====================================================================== */

    /* ---- rows become lists ----
       ★ `<ul class="rows"><li>` drops a class from every row and gives a
       screen reader a count ("list, 27 items") that a stack of <div>s cannot. */
    .rows { list-style: none; margin: 0; padding: 0; }
    /* ⛔ **D10: a card and a row inside a card looked alike.** Both were
       `--borderColor-default`, so at 1440 `/ui/usage` read as one continuous
       gray field rather than as seven cards containing rows.
       ★ A divider inside a container must be quieter than the container's own
       edge, or the hierarchy is stated twice at the same volume and therefore
       not stated. `-muted` measures 1.28:1 against a card - too weak for a
       CONTROL's boundary, which is what slice 5 established, and correct for a
       hairline that only has to separate. */
    .rows > li { padding: .6rem 0; border-bottom: 1px solid var(--borderColor-muted); }
    .rows > li:last-child { border-bottom: none; }
    /* ⭐ The LEGO page (`lego.html`), compact (owner, 2026-10-09). A SET is one grid line of about 52 px: the picture, the name over its number and facts, the location chip, the notes as one clamped line, Edit.
       Edit opens that row's editor UNDER the line (`.lego-editor`); nothing else on a row is a form. Tokens only: the page has no color of its own.
       ⚠️ The thumbnail is a fixed square with `object-fit: contain`, because Rebrickable's pictures are every proportion and a list of mixed heights is hard to scan. Its SIZE is the img's own width and height attributes
       (44 in the list, 56 in the variant picker), so the layout is reserved before the picture loads. */
    .lego-pick { display: flex; align-items: center; gap: .8rem; padding: .5rem 0; cursor: pointer; }
    .lego-thumb { display: block; flex: none; object-fit: contain; border-radius: 6px; background: var(--bgColor-field); }
    .lego-thumb-link { display: block; flex: none; line-height: 0; border-radius: 6px; }
    .lego-thumb-link:focus-visible { outline: 2px solid var(--fgColor-accent); outline-offset: 2px; }
    .lego-body { min-width: 0; flex: 1; }
    .lego-body > .meta { display: block; }
    .lego-add-row { align-items: flex-end; }
    .lego-num { width: 12rem; }
    .lego-add-notes { flex: 1 1 14rem; }
    /* The glyph legend (`/ui/glyphs`): a mark, then what it means. */
    .flag-text { white-space: pre-wrap; overflow-wrap: anywhere; }
    .glyph-rows { list-style: none; margin: 0; padding: 0; display: grid; gap: .35rem; }
    .glyph-row { display: grid; grid-template-columns: minmax(3.5rem, 7rem) 1fr; gap: .75rem; align-items: baseline; }
    .glyph-mark { font-size: 1.35rem; line-height: 1.3; white-space: nowrap; }
    .glyph-meaning { overflow-wrap: anywhere; }
    .glyph-drawing { word-break: break-all; }
    /* The dial cards (`/ui/dial-cards`): one card a dial, its buttons the rows and tap, double and hold the columns. Four to a page on paper: two across, two down, none split. */
    .dial-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(22rem, 1fr)); gap: 1rem; }
    .dial-card-table { width: 100%; border-collapse: collapse; table-layout: fixed; }
    .dial-card-table th:first-child { width: 4rem; }
    .dial-cards { margin-top: 1rem; }
    .dial-card-table th, .dial-card-table td { text-align: left; padding: .35rem .5rem; border-bottom: 1px solid var(--borderColor-muted); vertical-align: top; }
    .lego-add-notes > input { width: 100%; }
    .lego-rows > li { padding: .25rem 0; }
    .lego-line { display: grid; align-items: center; gap: .2rem .8rem; min-height: 44px;
                 grid-template-columns: 44px minmax(0, 1.5fr) 6.5rem minmax(0, 1fr) auto; grid-template-areas: "pic name chip notes edit"; }
    .lego-pic { grid-area: pic; }
    .lego-name { grid-area: name; min-width: 0; }
    .lego-name strong, .lego-name .meta { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .lego-name .meta { font-size: .78rem; }
    /* A fixed column, so every row's notes start at the same place; a long shelf title clamps rather than pushing them. */
    .lego-chip { grid-area: chip; justify-self: start; margin-left: 0; max-width: 100%; overflow: hidden; text-overflow: ellipsis; }
    .lego-notes { grid-area: notes; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .lego-edit-open { grid-area: edit; }
    .lego-editor { display: flex; flex-wrap: wrap; align-items: flex-end; gap: .6rem; padding: .5rem 0 .3rem 56px; }
    /* ⚠️ Two classes: `.field-pair > input { width: auto }` is one class and one element, so a lone `.lego-edit-label` loses to it. */
    .lego-editor .lego-edit-kind { width: 9rem; }
    .lego-editor .lego-edit-label { width: 7rem; }
    .lego-editor .lego-edit-notes-pair { flex: 1 1 14rem; }
    .lego-editor .lego-edit-notes { width: 100%; }
    /* ⭐ The hover preview: ONE element, placed by the script through two custom properties, inside the viewport. `pointer-events: none` so it can never take the hover away from the thumbnail it belongs to. */
    .lego-pop { position: fixed; left: var(--lego-pop-x, 0); top: var(--lego-pop-y, 0); z-index: 60; width: 20rem; padding: .4rem; pointer-events: none;
                background: var(--bgColor-layer1); border: 1px solid var(--borderColor-default); border-radius: 8px; }
    .lego-pop-img { display: block; width: 100%; height: auto; border-radius: 4px; }
    .lego-catalog-card > summary { cursor: pointer; }
    /* At phone width: the picture and the name, then the chip and the notes, then Edit. ⛔ No horizontal scroll: every cell may shrink and the long ones clamp. */
    @media (width < 44rem) {
      .lego-line { grid-template-columns: 44px 6.5rem minmax(0, 1fr); grid-template-areas: "pic name name" ". chip notes" ". edit edit"; }
      .lego-edit-open { justify-self: end; }
      .lego-editor { padding-left: 0; }
      .lego-num { width: 100%; }
    }

    /* ⭐ The kiosk history page (`kiosk_history.html`). Tokens only. A date is one row: its noon and midnight sets side by side, each a wrapped strip of 16:9 thumbnails (the WebP shown small; a click opens it full size). */
    .kh-fields { align-items: flex-end; gap: .8rem 1.2rem; }
    .kh-check { display: inline-flex; align-items: center; gap: .4rem; }
    .kh-slot { align-items: flex-end; }
    .kh-facts { list-style: none; margin: 0 0 .4rem; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); gap: .3rem 1.2rem; }
    .kh-fact { display: flex; flex-direction: column; min-width: 0; }
    .kh-fact strong { overflow-wrap: anywhere; }
    .kh-dates > li { padding: .6rem 0; }
    .kh-cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr)); gap: .6rem 1.2rem; margin-top: .3rem; }
    .kh-col { min-width: 0; }
    .kh-col h3 { margin: 0; font-size: .95rem; }
    .kh-thumbs { display: flex; flex-wrap: wrap; gap: .4rem; }
    .kh-thumb-link { display: block; line-height: 0; border-radius: 4px; }
    .kh-thumb-link:focus-visible { outline: 2px solid var(--fgColor-accent); outline-offset: 2px; }
    .kh-thumb { display: block; width: 6.5rem; max-width: 100%; height: auto; aspect-ratio: 16 / 9; object-fit: cover; border-radius: 4px; background: var(--bgColor-field); }

    /* ---- a day of letters ----
       ⭐⭐ **The break between days, made structural**, at the owner's
       direction (2026-09-14): *"we won't actually ever get delivery
       confirmation on any of these. let's make the break between days more
       obvious."* A letter never changes state after it is scanned, so the day
       is the only axis the list has - and a date chip repeated down every row
       stated that weakly while printing the same string five times.

       ⚠️ **A rule above the heading, not a border around the section.** A box
       inside a card is the hierarchy problem D10 above already settled: the
       card's own edge and an inner box at the same volume read as one field.
       A single hairline separates without claiming to contain.

       ★ The first day has no rule, because there is nothing above it to
       separate it FROM - the card's heading is already there. */
    .letter-day + .letter-day { margin-top: 1.1rem; padding-top: .9rem;
                                border-top: 1px solid var(--borderColor-muted); }
    /* ⚠️ `space-between` puts the count hard right, where the eye can compare
       counts down the column instead of hunting for each one after a label of
       varying length. */
    .letter-day-head { display: flex; align-items: baseline;
                       justify-content: space-between; gap: .5rem;
                       margin: 0 0 .2rem; font-size: .85rem;
                       color: var(--fgColor-muted); font-weight: 600;
                       text-transform: uppercase; letter-spacing: .03em; }

    /* ---- a delivery photo on a parcel row (BACKLOG 2.22) ----
       ⭐ A thumbnail, not the photo: at its own 187x250 it would be the tallest
       thing in the row and push the next parcel off a phone screen. The link
       opens the full image. ⚠️ `width`/`height` stay on the `<img>` from the
       photo's own dimensions, so `height: auto` keeps the aspect ratio and the
       row reserves the space before it loads - no jump when it arrives. */
    .delivery-photo { display: block; margin-top: .4rem; width: max-content; }
    .delivery-photo img { display: block; height: auto; width: auto;
                          max-height: 6rem; max-width: 100%;
                          border-radius: 4px;
                          border: 1px solid var(--borderColor-muted); }

    /* ⭐⭐ **Continue watching flows across, because each entry is small and
       there are several.** `auto-fit` at a 22rem floor gives one column on a
       phone, two at the 900px measure and three on a desktop - so the width a
       screen actually has becomes fewer screens of scrolling rather than a
       wider empty card.

       ⚠️ **The floor is 22rem for a reason, and the reason is `.tiles`.** That
       grid sat at `minmax(11rem)`, which was BELOW what its content needed, so
       giving the page more width bought more CRAMPED columns instead of roomier
       ones - measured 2026-09-03, seven columns where four had fitted, and text
       that had never wrapped began wrapping. A title like "See No Evil, Hear No
       Evil 1989" plus three buttons needs about 350px, so the floor is set
       above that rather than at the smallest thing that fits.

       ⛔ **The row separator becomes a cell separator, not a horizontal rule
       across the grid.** A `border-bottom` on every `li` in a two-column grid
       draws a line under one cell and not its neighbor, which reads as a
       broken table. Padding does the separating instead.
    ⭐⭐ **Generalized on 2026-09-03, deliberately BEFORE a second copy
       existed.** Five more lists wanted exactly this - Hue scenes, lights,
       rooms, zones and switches - and the shared-helper lesson from
       `infra/_gcloud.ps1` is that the same fix independently reinvented six
       times is one definition arriving five times too late. So the floor is a
       custom property and the rest is written once.

       ⚠️ **Two floors, not one, and not a floor per page.** A single number
       would either cramp the switch page's tap-dial cluster or waste half a
       column on a scene row - the content genuinely differs in width. Two
       named weights cover every list here; a third needs a reason. */
    /* ⛔⛔ **Desktop only, and the first attempt was not.** These rules were
       written unscoped, so `border-bottom: none` also stripped the separator
       between rows on a PHONE - where the list is one column, the separator is
       the only thing dividing one scene from the next, and there is no
       neighboring cell for it to look broken against. Left-packing the control
       hurt there too: at 360px the buttons had been a tidy right-aligned column,
       which reads fine precisely BECAUSE the separators bound each row.

       ★ So the multi-column layout and both of its consequences are enhancements
       above 50rem, and below it the page is byte-identical to what it was.
       Caught by rendering at 360 and diffing against the shot taken before the
       change - not by reasoning about it, which had already concluded the
       opposite. */
    @media (width >= 50rem) {
        .row-grid { --col-floor: 22rem;
                    display: grid;
                    grid-template-columns: repeat(auto-fit, minmax(min(var(--col-floor), 100%), 1fr));
                    gap: .25rem 2.5rem;
                    /* ⚠️ Cells top-align rather than stretch. Hue rooms carry a
                       `<details>` expander, and without this, opening one would
                       stretch every sibling in its row to match. */
                    align-items: start; }
        .row-grid > li { border-bottom: none; padding: .6rem 0 .9rem; }

        /* ⛔⛔ **The control follows its name; it is NOT pushed to the cell edge.**
           `.row-head` right-aligns its last child, which is correct in one wide
           column and dangerous in three narrow ones. Measured at 1720 on the first
           attempt: a scene's Recall button sat ~350px from the name it belongs to
           and ~60px from the NEXT column's name - so every button was nearly six
           times closer to the wrong scene than to its own.

           ⚠️ These are not cosmetic controls. Recall sets a whole room's lights and
           a toggle switches a real bulb, both on one press with no confirmation -
           the same class of harm as the Continue buttons above, which is why the
           row layout there was fixed rather than tolerated.

           ★ Left-packing costs the tidy right-aligned column of controls, and that
           is the trade being made deliberately: alignment is aesthetic, binding is
           safety. A long name can still push its control near the cell's edge, but
           it stays adjacent to its OWN name, so proximity never favors a
           neighbor. */
        .row-grid .row-head:has(button, input, select) { justify-content: flex-start; }

    /* ★ **Keyed on `:has(button, input, select)` rather than on being in a
       grid, because pressing the wrong thing is the entire reason.** A
       read-only row has no such hazard, and right-aligning its value gives a
       scannable column of numbers that is worth keeping - `/ui/usage` reads as
       seventeen percentages down one edge. The first version left-packed every
       grid row and would have thrown that away to solve a problem those rows do
       not have.

       ⚠️ `:has()` is already load-bearing here (`.api-doc .rows > li:has(...)`),
       so this adds no new support assumption. */

        /* A name, a tag and one button. Measured need ~340px, so 384. */
        .row-grid--compact  { --col-floor: 24rem; }
        /* Sliders, color swatches, a four-button dial cluster. Measured ~400px. */
        .row-grid--controls { --col-floor: 26rem; }
    /* ⚠️ **A third weight, and the comment above said one would need a
       reason.** The usage meters are not a name and a number: each row carries
       three to five lines of explanatory prose - which meter, which price, read
       on which date - and at 26rem that prose wraps to seven lines and the row
       gets TALLER than it was in one column. 34rem gives each column the ~630px
       the text already had, so two columns halve the page instead of
       re-flowing it into something worse. */
    .row-grid--prose    { --col-floor: 34rem; }
    }

    /* ⛔⛔ **`minmax(26rem, 1fr)` does NOT collapse below 26rem - it
       OVERFLOWS.** `auto-fit` removes empty tracks; it does not shrink a track
       under its own minimum. So on a 360px phone a 26rem (416px) floor made the
       card 416px wide inside a 360px viewport, and the page scrolled sideways
       with the right-hand end of every row cut off. `min(26rem, 100%)` is the
       fix: the floor is 26rem where there is room and the container width where
       there is not.

       ⚠️ **This shipped.** It went out in #593 on `/ui/traffic` and was found
       the next time a 360px shot was actually OPENED rather than diffed - the
       Hue pages had been compared byte-for-byte against their originals, which
       proved they had not changed, while this card was new and so had nothing
       to be compared against. A guard now covers it:
       `test_guards_stylesheet.py::test_a_grid_floor_cannot_exceed_a_phone`.

       ★ `.row-grid` was never affected - it lives above 50rem and its widest
       floor is 34rem - but its floor is a custom property any page can set, so
       it is clamped too rather than left correct by coincidence. */
    /* ⭐ **Two sibling cards, side by side - the same principle one level up.**
       `/ui/traffic` opens by calling itself "two questions that share a word",
       and then stacked them, so the contrast it exists to draw was never once
       on screen together. */
    .card-pair { display: grid;
                 grid-template-columns: repeat(auto-fit, minmax(min(26rem, 100%), 1fr));
                 gap: 1rem; align-items: start; }
    .row-head { display: flex; justify-content: space-between; align-items: center;
                gap: .75rem; margin-bottom: .4rem; }
    /* ⛔ **D1b: 735px between a label and its own value, at 1440.** On
       `/ui/usage` a row's service name sits hard left and its number hard right
       inside an ~880px card - across rows that are five and six lines tall, so
       by the time the eye has traveled right it has passed two other rows'
       worth of vertical space.

       ⚠️ **Removing `space-between` was the wrong fix and this is why:** the
       right-aligned column is what lets a reader scan the numbers down the page
       without reading the labels. The row is not wrongly ALIGNED, it is too
       WIDE.

       ★ So cap the measure, and take the token that already exists rather than
       inventing a number - which is precisely §3.2's lesson, where
       `--measure-prose` existed and the thing that needed it was not taking it.
       At 68ch this roughly halves the travel while keeping the scan column. */
    /* ⛔⛔ **D1b was fixed for ROWS and not for the cards it was measured on** -
       `/ui/usage`'s summary cards put `.row-head` directly inside `.card`, so
       the page the defect was found on kept it: measured 2026-09-03 at a 1520px
       viewport, `Trial credit`'s figure sat ~860px from its label while every
       row value below it stopped at 68ch. ★ The visible symptom was "the column
       widths are inconsistent", and that is exactly right - two figures on the
       page were not in the same scan column as the other sixteen.

       ⛔⛔ **Capping `.card > .row-head` was tried and REVERTED the same hour,
       because the render showed it moves the defect rather than fixing it.** On
       `/ui/service-health`, whose cards are ~900px, the `resolved` and `ok`
       pills stopped at 68ch and floated in the middle of their card instead of
       sitting on its right edge. ⚠️ The comment here claimed those pages "had
       the same latent gap" - they did not, and the claim was written before the
       page was looked at.

       ⭐ **The measure belongs to the CARD, not to the row inside it.** A card
       narrowed to the measure makes the row-head fill it, so the value lands on
       the card's edge with no cap needed at all - which is `main--prose` above,
       and it is why no card-level rule appears below. Two mechanisms competing
       for one right edge is what produced the floating pill. */
    .paired > li > .row-head { max-width: var(--measure-prose); }
    /* ⛔ **A name and its caption are text, and this made them a flex row.**

       `.row-name` was `display: flex; flex-wrap: nowrap`, holding a bare title
       text node beside a `<span class="meta">` year. A bare text node in a flex
       container becomes an anonymous flex item, and an anonymous item shrinks:
       so a title long enough to need shrinking wrapped INSIDE its own item
       while still occupying the full width, and flung its year to the right
       edge of the row.

       Measured on `/ui/netflix` at 390px, 2026-09-01 - fourteen rows, thirteen
       of them one line long:

           adjacent  10 Days of a Curious Man    1 line   gap title→year   2px
           FLUNG     100 Humans: Life's Ques…    2 lines  gap title→year 214px

       ★ **Inline flow is the fix, and it is less CSS than the bug was.** In one
       inline flow the year always follows the last word, whatever the title's
       length, and the whitespace already in the templates is the separator that
       `gap` was providing.

       ★ **It also retires the superscript caption by construction rather than
       by rule.** `.meta` carries `margin: 0 0 .4rem` for use as a PARAGRAPH,
       and in a flex row that bottom margin lifted the text inside its box - the
       defect slice 1 introduced and slice 9 guarded. Vertical margins do not
       apply to a non-replaced inline element, so there is nothing left to
       cancel. The `margin: 0` stays anyway: it costs nothing and it keeps the
       intent legible if this ever becomes a block again.

       ⚠️ `min-width: 0` must stay - `.row-name` is still a flex ITEM of
       `.row-head`, and without it a long name refuses to shrink and pushes the
       row's controls off the card. */
    .row-name { min-width: 0; }
    .row-name .meta { margin: 0; }
    /* ★ A badge or an icon button is a box, not text: `vertical-align` is the
       inline-flow equivalent of the `align-self: center` these carried while
       this was a flex row. */
    .row-name > .badge, .row-name > .btn, .row-name > button { vertical-align: middle; }

    /* ---- meta text, with a severity tone ----
       ⚠️ Replaces `.scheduled-meta` on the converted pages, and the
       `.connectivity-issue` modifier with a `data-tone`. `connectivity-issue`
       named a *situation*, so the battery line had to invent its own use of the
       same class to get the same red - the tone names the severity instead, and
       three lines share it. */
    .meta { font-size: .85rem; color: var(--fgColor-muted); margin: 0 0 .4rem; }
    /* ⛔ **The measure existed and prose was not taking it.** `--measure-prose`
       (68ch) is applied to `.md > p`, `.event-detail` and `.empty-why` - but
       `.meta` carries nearly all the card body copy in this app, and `main` is
       capped at `--measure-wide` (900px). At 1440 that is about 120 characters
       a line, well past the 45-75 the token itself names.

       ⚠️ `p.meta` rather than `.meta`: the class is also worn by short inline
       `<span>`s beside badges and figures, and constraining those would be
       meaningless at best. Scoping to the paragraph says the intent out loud
       instead of relying on max-width being inert on inline elements. */
    p.meta { max-width: var(--measure-prose); }
    /* ⭐⭐ **Every paragraph gets the measure, not only `p.meta`** - and this is
       the change that makes giving a page more width safe at all.

       ⛔ Measured 2026-09-03: only `p.meta` and three other classes capped
       themselves, so an unclassed `<p>` took the full `main` width. At the 900px
       default that is already ~112 characters against a 68ch target, so these
       were over-wide before anybody widened anything - `service_health.html`'s
       socketpair paragraph and `plex.html`'s "no token is set" among them.

       ★ **And it is why nobody could widen a page.** `main--wide` is 1360px, so
       widening a page containing a bare `<p>` turned that prose into ~170
       character lines. The width could not be given until the prose stopped
       taking it, which made "pages do not fill the screen" look like a layout
       preference when it was a blocked prerequisite.

       ⚠️ **Specificity is why this does not fight anything.** `main p` is 0-0-2,
       which loses to `p.meta` at 0-1-1 and to `.empty-why` at 0-1-0 - so every
       class that already sets its own measure keeps it, and this only reaches
       paragraphs that had nothing. That is the same cascade arithmetic that bit
       this file twice in one commit on 2026-09-01, applied deliberately.

       ★ Harmless on the short classed paragraphs it also reaches - `chip-time`,
       `model-line`, `tile-figure`, `field-error` are all well under 68ch, so a
       ceiling they never touch changes nothing. */
    main p { max-width: var(--measure-prose); }
    .meta[data-tone="danger"] { color: var(--fgColor-danger); }
    .meta[data-tone="warn"]   { color: var(--fgColor-attention); }

    /* ---- utilities ---- */
    .row { display: flex; align-items: center; gap: .6rem; }
    /* ★ A compact button for lists, where the control repeats on every row.
       ⚠️ Full-size `.btn` on a 100-row listing put a 2.25rem control against
       its neighbor above and below with nothing between them - the rows read
       as one block of buttons rather than one button per title. */
    .btn-sm { min-height: 1.7rem; padding: .15rem .55rem; font-size: .8rem;
              border-radius: 5px; flex: 0 0 auto; }
    /* The list rows those buttons live in: a gap is what separates one title
       from the next, vertically as well as horizontally. */
    /* ⛔ **`.play-list` is gone, and what killed it is worth keeping.** It was
       `display:flex; flex-wrap: wrap`, so a row's button and its title shared
       one inline flow and which button belonged to which title was decided by
       how long the title happened to be. Six lists carried it; all six are
       `.rows` now. `test_guards_row_controls.py` holds the rule.

       ⚠️ The lesson it learned did NOT come with it, and that is the second
       defect this comment records: `align-items: baseline` and `.meta
       { margin: 0 }` were written here for a caption that looked superscript,
       and `.row-name` is another flex context with the same mixed font sizes
       and the same `.meta` inside it. Nothing told it. See `.row-name` above. */
    .library-list .meta { margin: 0; }
    .library-list { list-style: none; padding: 0; margin: .4rem 0 0; }
    .library-list li { display: flex; align-items: baseline; gap: .5rem;
                       padding: .25rem 0; flex-wrap: wrap; }
    /* A checkbox is not text and has no baseline worth aligning to. */
    /* ⭐ **Down to what it is actually for.** The width, the height and the
       skin all come from `input[type="checkbox"]` above now, so this is left
       with the one thing that IS about being in this list: sitting on the text
       baseline of its own row.

       ★ **And the list is verified at last.** `/ui/plex` only draws it for a
       server browsable from this network and no capture has had one - so the
       swatch page renders the same markup against this stylesheet instead.
       ⚠️ That verifies the STYLESHEET, not the page: layout and surrounding
       copy are absent from a swatch. */
    .library-list input[type="checkbox"] { align-self: center; margin: 0; }
    /* ⚠️ `aria-pressed` is the state, so the style follows it rather than a
       class the script has to remember to keep in sync. */
    .target-row { flex-wrap: wrap; gap: .4rem; }
    /* ⛔ **The border, not the text.** These chips "nearly vanish" in dark
       theme, and the review read that as weak gray text - but the text is
       5.65:1 light and 5.78:1 dark, comfortably legible. What fails is the
       BOUNDARY: `--borderColor-muted` against a card is 1.28:1 light and
       1.68:1 dark, far under the 3:1 a control's visual boundary needs. So the
       thing that says "this is a button" was the invisible part.

       ⚠️ These decide which television the whole page acts on, which is why a
       boundary is not decoration here. `--borderColor-emphasis` measures 3.08:1
       and 3.03:1 - and its own comment records it being raised once before for
       exactly this reason. */
    .target-row .btn[aria-pressed="false"] { background: var(--bgColor-field);
                                             color: var(--fgColor-muted);
                                             border: 1px solid var(--borderColor-emphasis); }
    /* ⛔ **A hardcoded leading separator, for the third time.** Slice 1 fixed
       one in `_continue_watching.html` and §2.4 recorded the cause -
       "a hardcoded leading `&middot;`", not a blank field contributing its own.
       It came back on `/ui/shipping`, where a meta line is a chain of optional
       `&middot; fragment` pieces: with the earlier ones absent the line OPENED
       with a separator, and the capture read "· from G & S".

       ★ **The fix is structural rather than another site edit.** A separator
       between things belongs to the layout, so it goes on the container: an
       absent fragment leaves no element, and no element cannot leave a
       separator behind. The next dot-joined line gets this for free.

       ⚠️ **Not a Jinja `join`, and the reason is security.** Joining these
       would need `|safe` to keep the tracking number's `<span class="tnum">`,
       and the same string carries a merchant name and a location that came from
       a carrier's API. `|safe` over third-party text is an injection, and
       tidiness is not worth one. */
    /* ⛔ **The trailing space collapsed, and `.dotted` had been relying on
       source whitespace to hide it.** Every earlier user of this class puts a
       newline between its children, so the gap after the separator came from
       the TEMPLATE and not from here. The catalog's new row - written with no
       whitespace between the spans, deliberately, so that no stray gap could
       appear - rendered `2020 ·movie`.

       ★ `margin-right` instead of a second space: a margin cannot be collapsed
       by white-space processing, so the gap is the same whatever the template
       looks like. ⚠️ The leading space STAYS a real space, because it is the
       only break opportunity in the sequence - without it a long dotted list
       becomes one unbreakable line. */
    .dotted > *:not(:first-child)::before { content: " \00b7"; margin-right: .3em; }
    .tnum { font-variant-numeric: tabular-nums; }
    .nowrap { white-space: nowrap; }
    .is-hidden { display: none; }
    /* Available to a screen reader, invisible on screen. Not `display: none`,
       which removes it from the accessibility tree along with everything else. */
    .visually-hidden { position: absolute; width: 1px; height: 1px; margin: -1px;
                       padding: 0; overflow: hidden; clip: rect(0 0 0 0);
                       white-space: nowrap; border: 0; }

    /* ---- PR3: the D-pad and the filter chips ---- */

    /* ★ A grid, not a wrapping row.
       Eight buttons in a flex row reflowed to a different shape at every
       width, so the arrow that was "up" moved as the window changed. A D-pad
       is only readable because of its spatial mapping; keeping that mapping
       fixed is the entire point of the control. */
    /* ★ The remote is one block, and the wrapper owns the width and the gaps.
       ⚠️ Power and volume were separate flex rows: nothing set a vertical gap
       between them, so they touched, and each sized to its own content, so
       neither matched the D-pad. */
    .remote { display: grid; gap: .5rem; max-width: 16rem; margin: .6rem 0; }
    .remote-row { display: grid; gap: .4rem; }
    .remote-power { grid-template-columns: repeat(2, minmax(0, 1fr)); }
    /* Three columns, so the volume row's cells line up with the D-pad's. */
    .remote-vol { grid-template-columns: repeat(3, minmax(0, 1fr)); }

    .dpad { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr));
            gap: .4rem; }
    .remote button { min-height: var(--control-height); padding: .4rem .35rem;
                     white-space: nowrap; }
    .dpad-nw { grid-area: 1 / 1; }
    .dpad-up { grid-area: 1 / 2; }
    .dpad-ne { grid-area: 1 / 3; }
    .dpad-left { grid-area: 2 / 1; }
    .dpad-ok { grid-area: 2 / 2; }
    .dpad-right { grid-area: 2 / 3; }
    .dpad-down { grid-area: 3 / 2; }
    .dpad-se { grid-area: 3 / 3; }
    /* The grid takes focus for its arrow-key handler, so it needs to show it. */
    .dpad:focus-visible { outline: 2px solid var(--fgColor-accent); outline-offset: 4px; }

    .row.wrap { flex-wrap: wrap; }
    .app-select { max-width: 20rem; }
    .scan-select { max-width: 9rem; display: inline-block; }
    .text-entry { max-width: 15rem; display: inline-block; }
    .num-entry { max-width: 5rem; display: inline-block; }

    /* ---- on-device schedules ------------------------------------------------
       ⭐ Collapsed by default, and that is a content decision rather than a
       space-saving one. Most outlets hold no rules, and an always-open form
       under every row would make the page read as a scheduler that happens to
       show power - which is backwards. The count in the summary is what tells
       a reader whether opening it is worth it. */
    .schedule-panel { margin-top: .6rem; }
    .schedule-panel > summary { cursor: pointer; font-size: .85rem;
                                color: var(--fgColor-muted);
                                display: flex; align-items: center; gap: .4rem; }
    .schedule-panel > summary:hover { color: var(--fgColor-default); }
    /* ⛔ `display: flex` above deletes the UA's disclosure triangle, so the row
       would render as plain text with no sign it opens - and nothing would
       fail. `test_guards_css_summary_marker.py` catches exactly this; the
       marker below is what it is asking for. */
    /* ⛔ `display: flex` above deletes the UA's disclosure triangle, so
       the row would render as plain text with no sign that it opens - and
       nothing would fail. `test_guards_css_summary_marker.py` catches
       exactly this; the marker below is what it is asking for.

       ⚠️ The glyph is written as the CHARACTER, not as a CSS escape. The
       escape form was tried first and a shell heredoc collapsed its
       backslash, leaving a literal 0x15 control byte in this file - which
       `test_no_source_file_carries_a_control_character` caught. */
    .schedule-panel > summary::after {
      content: "▸"; display: inline-block; font-size: .8em;
      color: var(--fgColor-subtle);
      transition: transform var(--motion-fast) ease;
    }
    .schedule-panel[open] > summary::after { transform: rotate(90deg); }
    .schedule-panel[open] > summary { margin-bottom: .6rem; }

    /* ⚠️ The checkbox itself is hidden but still focusable and still the
       control - the label's box is only its presentation. Replacing it with a
       div and a click handler would lose keyboard access and the whole
       checkbox semantic, which is the usual way this pattern goes wrong. */
    .day-picker { display: inline-flex; gap: .25rem; }
    .day-box { position: relative; display: inline-flex; }
    .day-box input { position: absolute; opacity: 0; width: 100%; height: 100%;
                     margin: 0; cursor: pointer; }
    .day-box > span[aria-hidden] {
      display: inline-flex; align-items: center; justify-content: center;
      width: 1.7rem; height: 1.7rem; border-radius: 50%;
      border: 1px solid var(--borderColor-default);
      font-size: .75rem; color: var(--fgColor-muted);
    }
    .day-box input:checked + span[aria-hidden] {
      background: var(--bgColor-field); color: var(--fgColor-default);
      border-color: var(--borderColor-emphasis);
    }
    /* ⚠️ Focus must remain visible even though the input is transparent, or
       the picker is unusable by keyboard - the failure this pattern invites. */
    .day-box input:focus-visible + span[aria-hidden] {
      outline: 2px solid var(--borderColor-emphasis); outline-offset: 2px;
    }

    /* Filter chips. The count is what makes a zero visible before you tap. */
    /* The feed's page-size control on its own row, so the number, the
       ceiling and the button stay on one line at narrow widths - the first
       version trailed a sentence after the button and it truncated on a
       phone. */
    .feed-size { display: flex; align-items: center; gap: .5rem;
                 flex-wrap: wrap; margin: .6rem 0 .25rem; }
    .feed-size .num-entry { max-width: 5rem; }
    /* Tight under the control it explains, and clearly subordinate to it. */
    .feed-size-note { margin: 0 0 .9rem; font-size: .8rem; }
    .chips { flex-wrap: wrap; margin-bottom: .8rem; }
    /* ⛔ **D2's collision, on the control D2 did not reach.** The view-nav's
       inactive tabs stopped borrowing `.off` - this app's "an action you can
       take", worn by a row's Play button - and the event filter chips still
       wore it. So an unselected FILTER rendered identically to a control that
       starts something on a television.

       ★ Same fix, keyed on the same kind of state: these carry `aria-pressed`,
       so the style follows the attribute a screen reader is given rather than a
       class the template has to remember. Outline means "selectable, not
       currently selected" here exactly as it does in the nav.

       ⚠️ `--borderColor-emphasis` for the 3:1 a control's boundary needs; the
       `-muted` token measures 1.28:1 on a card, which is what made the "Play
       on" chips vanish in slice 5. */
    .chips button[aria-pressed="false"] {
      background: transparent;
      color: var(--fgColor-default);
      border: 1px solid var(--borderColor-emphasis);
    }
    .chips button[aria-pressed="false"]:hover { background: var(--bgColor-field); opacity: 1; }

    /* The text filter over a `.rows` list. */
    .row-filter { margin-bottom: .9rem; }
    .row-filter input { max-width: 22rem; }
    .chip-count { font-size: .75em; opacity: .75; margin-left: .15rem; }
    /* ⚠️ A kind with no events is still tappable - it just says so. Disabling
       it would hide the fact that the kind exists at all, which is the bug
       the derived chips were fixing. */
    .chips button[data-zero="true"] { opacity: .55; }

    /* ⭐ **The second level, and it has to LOOK like a second level.**

       Rendered identically to the row above it, two chip rows read as one
       control that wrapped - and then "Contact" looks like a peer of "Hue"
       rather than a part of it. Three cheap signals, none of them a new
       color: indented past the parent row, smaller text, and a lead-in naming
       the kind it belongs to.

       ⚠️ The indent is `1rem`, not a tab stop under the Hue chip. Its position
       depends on how many kinds precede it and on where the row wrapped, so
       anything that appears to point at it would be pointing somewhere else
       on the next page load. */
    .sub-chips { margin: -.4rem 0 .9rem; padding-left: 1rem;
                 align-items: center; }
    .sub-chips button { font-size: .85em; }
    /* The lead-in. ⚠️ Not a `<label>` - it labels a group of buttons rather
       than a form control, and the group carries its own `aria-label`. */
    .sub-chips .meta { margin-right: .15rem; }

    /* ---- the switch ----
       ⚠️ `role="switch"` only where the state is authoritative and patched
       from the response. Hue qualifies: POST /tasks is synchronous and the Hue
       handler acts in-process, so `succeeded` means the light moved. Roku does
       not - reachability and power_mode are probed and can disagree - so it
       keeps explicit On/Off. See docs/frontend-primitives.md. */
    .switch { position: relative; background: none; border: none; padding: .3rem;
              min-width: var(--control-height); min-height: var(--control-height);
              display: inline-flex; align-items: center; cursor: pointer; }
    .switch-track { width: 2.6rem; height: 1.5rem; border-radius: 999px;
                    background: var(--bgColor-field);
                    border: 1px solid var(--borderColor-muted);
                    display: block; position: relative;
                    transition: background-color var(--motion-fast) ease,
                                border-color var(--motion-fast) ease; }
    .switch-knob { position: absolute; top: 2px; left: 2px;
                   width: calc(1.5rem - 6px); height: calc(1.5rem - 6px);
                   border-radius: 50%; background: var(--fgColor-muted);
                   transition: transform var(--motion-fast) ease,
                               background-color var(--motion-fast) ease; }
    .switch[aria-checked="true"] .switch-track { background: var(--bgColor-success-emphasis);
                                                 border-color: var(--borderColor-success); }
    .switch[aria-checked="true"] .switch-knob { background: var(--fgColor-success);
                                                transform: translateX(1.1rem); }
    .switch:hover .switch-track { border-color: var(--borderColor-emphasis); }

    /* ---- busy ----
       One rule for every control the runtime is waiting on, so "something is
       happening" never has to be re-implemented per page. */
    [data-busy] { opacity: .55; cursor: progress; }

    /* ---- danger ----
       ★ Delete used to wear `.off`, the same gray as Disable - a destructive
       action and a reversible one looking identical. */
    button.danger { background: var(--bgColor-danger-muted); color: var(--fgColor-danger);
                    border: 1px solid var(--borderColor-danger); }
    button.danger:hover { background: var(--borderColor-danger);
                          color: var(--fgColor-onEmphasis); opacity: 1; }

    /* ★ **The FAILING badge bug.**
       `scheduler.html` shipped `<span class="badge off">FAILING</span>`, and
       `button.off` is ELEMENT-QUALIFIED - so the span matched nothing and a
       failing relay schedule rendered as an ordinary gray badge, identical to
       every other one. This is the variant it needed. */
    .badge.danger { background: var(--bgColor-danger-muted); color: var(--fgColor-danger); }
    /* ⚠️ The step below danger, added with the usage page's percent-of-tier
       badge. Same lesson as the FAILING bug above: a modifier with no rule
       renders as an ordinary gray badge, so "80% of the free tier" would look
       exactly like "3% of it". */
    .badge.warn { background: var(--bgColor-attention-muted); color: var(--fgColor-attention); }

    /* Per-field validation. ⚠️ An error slot must add no vertical space until
       it has something to say - six always-present rows would push the submit
       button off a phone screen.

       ★ **`margin`, not `display`.** This was `.field-error:empty { display:
       none }`, which is the SAME defect PR6 removed from `.ui-alert` twenty
       lines down, on six more elements: every one of these is `role="alert"`,
       and `display: none` keeps a live region out of the accessibility tree
       until text arrives - precisely when an announcement becomes unreliable,
       because a region inserted at the same moment as its content may not be
       announced at all.

       ⚠️ Zeroing the margin costs nothing, because an empty block `<p>` with
       no margin generates no line box. The region stays in the tree, occupies
       no space, and announces when JS writes into it. */
    .field-error { color: var(--fgColor-danger); font-size: .8rem; margin: .25rem 0 0; }
    .field-error:empty { margin: 0; }
    .dlg-message { color: var(--fgColor-muted); font-size: .9rem; margin: 0; }

    /* ---- PR6: the dashboard tiles ----
       ★ Replaced ten cards that each said "Open". A directory of links, on a
       page whose only content was the names of the other pages - all of which
       are in the nav bar. The tiles are state; the nav is navigation. */
    /* ⚠️ **§3.5 reported the final row as ragged, and D3 answered it without
       touching this rule.** Ten tiles in four columns is 4 + 4 + 2, which is
       arithmetic rather than a defect - and the two left over are now `Spec` and
       `Backlog`, the two that are about the CODE rather than the house. So the
       short row reads as a deliberate separation instead of a gap.
       ⛔ Left alone on purpose: `auto-fit` is what makes the grid respond at all,
       and the alternatives are inventing a filler tile or hard-coding a column
       count. Neither beats a row that now means something. */
    .tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
             gap: 1rem; margin-bottom: 1rem; }
    .tiles .card { margin-bottom: 0; }
    .tile h2 { margin: 0 0 .5rem; font-size: 1rem; }
    .tile h2 a { color: var(--fgColor-default); text-decoration: none; }
    .tile h2 a:hover { color: var(--fgColor-accent); }
    .tile-figure { font-size: 2.4rem; font-weight: 300; line-height: 1;
                   margin: 0 0 .35rem; }
    .tile-of { font-size: .9rem; color: var(--fgColor-muted); font-weight: 400; }
    /* ⛔ **D3: ten tiles at one weight, and two of them are not about the
       house.** Lights, Weather, TV, Network, Automation, Events, Credentials and
       Voices report the state of a home. `Spec` and `Backlog` count sections of
       two documents about the CODE - and at 2.4rem they claimed the same rank,
       so a reader scanning for "is anything wrong" met "353 open items" in the
       same voice as "22 of 23 devices present".

       ★ **Ranked, not hidden.** The figure drops to the size of a subheading:
       still a number, still scannable, no longer competing with house state.
       The tiles keep their place, because they ARE part of what this app is -
       just not part of what the house is doing. */
    .tile-reference .tile-figure { font-size: 1.5rem; font-weight: 400; }
    /* ⚠️ A configured COUNT, not a reading. `.tile-figure` at 2.4rem beside a
       temperature and a lights-on count implied all three were measurements of
       the same kind; this one comes from an environment variable and never
       changes. Same family, deliberately one step down. */
    .tile-static { font-size: 1.2rem; font-weight: 400; line-height: 1;
                   margin: 0 0 .35rem; color: var(--fgColor-default); }
    .tile-links { font-size: .82rem; margin: .5rem 0 0; }
    /* ⭐ The home page's three sections (owner, 2026-09-26): a label over each
       grid, quiet enough that the tiles stay the page. */
    .tile-section {
      margin: 1.1rem 0 .5rem; font-size: .72rem; font-weight: 600;
      letter-spacing: .08em; text-transform: uppercase; color: var(--fgColor-subtle);
    }
    .tile-block:first-child .tile-section { margin-top: 0; }
    .tile-links a { display: inline-block; padding: .25rem 0; }
    /* ⭐⭐ **On a phone the tiles are rows, not cards** (owner, 2026-09-24:
       "even on larger phone resolutions ... the home page is basically useless
       because there's only one tile per row and the tiles take a bunch of
       vertical space"). Below ~30rem the 14rem grid is one column anyway, and
       two tiles side by side at phone width was judged not to look right, so
       the answer is height: ~130px a tile at 390px became about half that.
       ★ The name and the figure share the first line, figure right-aligned
       like a reading on an instrument panel; every other line of the tile
       spans underneath. CSS alone, because every tile is the same shape (an
       `h2` and `p`s), so the markup, and the wide layout, are untouched.
       ⚠️ 32rem, not the grid's own ~29.5rem break, so a phone in portrait
       (up to ~430px) always gets it and nothing wider does. */
    @media (width < 32rem) {
      .tiles { gap: .5rem; }
      .tile {
        display: grid; grid-template-columns: minmax(0, 1fr) auto;
        column-gap: .75rem; align-items: baseline;
        padding: .6rem .9rem;
      }
      .tile h2 { grid-column: 1; grid-row: 1; margin: 0; }
      .tile .tile-figure, .tile .tile-static {
        grid-column: 2; grid-row: 1; margin: 0; text-align: right;
      }
      .tile .tile-figure { font-size: 1.5rem; }
      .tile-reference .tile-figure { font-size: 1.2rem; }
      .tile .tile-static { font-size: 1.05rem; }
      .tile > p:not(.tile-figure):not(.tile-static) {
        grid-column: 1 / -1; margin: .1rem 0 0;
      }
      /* ⚠️ A phone shows a tile's headline and its first line; the extra
         lines are for a wider screen (owner, 2026-09-26). */
      .tile .tile-extra { display: none; }
      .tile .tile-links { margin-top: .15rem; }
    }

    /* ---- the sign-in page ----
       ⚠️ `min-height: 100dvh`, not `height: 100vh`. On mobile Safari `100vh`
       is the viewport with the URL bar HIDDEN, so a page sized to it is taller
       than what you can see while the bar is showing - the sign-in button sat
       below the fold on first paint and the page could not scroll to it,
       because `height` (not `min-height`) also forbade growing. */
    .login { display: flex; align-items: center; justify-content: center;
             min-height: 100dvh; }
    .login-box { text-align: center; }
    .login-box .field-error { margin-top: 1rem; min-height: 1.2em; }

    /* ---- the shared empty / not-connected state ----
       ⚠️ Three near-identical "is Hue connected?" paragraphs existed, one per
       view, each with its own wording. One primitive, one CTA. */
    .empty { text-align: center; padding: 1.5rem .5rem; color: var(--fgColor-muted); }
    .empty-title { color: var(--fgColor-default); font-weight: 600; margin-bottom: .3rem; }
    .empty-why { font-size: .9rem; margin-bottom: .9rem; max-width: var(--measure-prose);
                 margin-inline: auto; }

    /* ---- the alert live region ----
       Invisible until it has something to say, so an empty region is not an
       empty box on every page. */
    .ui-alert { box-sizing: content-box; color: var(--fgColor-danger);
                font-size: .9rem; margin: 0;
                max-width: var(--measure-wide); margin-inline: auto;
                padding: 0 1rem; }
    /* ⚠️ `.ui-alert:empty { display: none }` was here and has been removed.
       It kept an ASSERTIVE live region out of the accessibility tree until
       text arrived - which is precisely when an announcement becomes
       unreliable, because a region inserted at the same moment as its content
       may not be announced at all. It bought nothing either: an empty block
       <p> with `margin: 0` generates no line box. */

    /* ---- the dialog ---- */
    /* ★ The box-sizing reset fixed a latent bug here. `calc(100vw - 2rem)`
       plainly intends "fit the viewport with a margin", but under content-box
       the padding and border sat *outside* it, so the real outer width was
       ~100vw + 10px and the dialog overflowed at narrow widths. Border-box
       makes the declaration mean what it says. */
    .dlg { background: var(--bgColor-layer1); color: var(--fgColor-default);
           border: 1px solid var(--borderColor-muted); border-radius: 10px;
           padding: 1.25rem; max-width: min(28rem, calc(100vw - 2rem)); }
    .dlg::backdrop { background: rgb(0 0 0 / .6); }
    .dlg h2 { margin: 0 0 .75rem; font-size: 1.05rem; }
    /* ⚠️ `.dlg-title`, `.dlg-message`, `.dlg-confirm` and `.dlg-cancel` are
       JAVASCRIPT hooks - `_ui.html` writes into them. `.dlg h2` above styles
       the title, so `.dlg-title` deliberately has no rule of its own; it is
       listed in the end-state guard's allow-list rather than given a
       do-nothing declaration to keep a scanner happy. */
    .timer { display: flex; align-items: center; gap: .4rem; flex-wrap: wrap; }
    .dlg-label { display: block; font-size: .85rem; color: var(--fgColor-muted);
                 margin-bottom: .25rem; }
    .dlg-actions { margin-top: 1rem; }

    /* ---- the rotary, as SVG ----
       ★ This is what takes hue_switches.html's inline `style=` to zero.

       The old version was a conic-gradient in a style attribute, because the
       percentage is per-row and CSS cannot read a value out of markup -
       `attr()` for non-string types ships nowhere. `stroke-dasharray` on an
       SVG circle with `pathLength="100"` is a *presentation attribute*, so the
       percentage rides in the markup with no style attribute at all.

       It also deletes the double text-shadow that existed only to keep the
       glyph legible over that gradient: a stroked ring leaves the middle on
       the card's own background. */
    .rotary { width: 2.6rem; height: 2.6rem; display: block; }
    .rotary-track { fill: none; stroke: var(--bgColor-field); stroke-width: 4; }
    .rotary-fill { fill: none; stroke: var(--fgColor-success); stroke-width: 4;
                   transform: rotate(-90deg); transform-origin: 50% 50%; }
    .rotary-glyph { fill: var(--fgColor-default); font-size: 13px; font-weight: 600;
                    text-anchor: middle; dominant-baseline: central; }

    .wx-now { display: flex; align-items: center; gap: 1.2rem; margin-bottom: 1rem; }
    .wx-temp { font-size: 3.2rem; font-weight: 300; line-height: 1; }
    .wx-desc { font-size: 1.05rem; }
    .badge.wx-precip { background: var(--bgColor-accent-muted); color: var(--fgColor-accent); }
    /* ★ **The forecast temperatures line up down the column.**
       `.row-head` is `space-between`, so this group sits flush right and its
       width varies with its contents - which meant a row carrying a precip
       badge pushed its temperature left and the numbers stopped aligning.
       Reported from the page.

       Two parts, and both are needed: the markup puts the temperature LAST so
       it is always against the right edge, and `min-width` here makes 2- and
       3-digit values occupy the same slot. `tnum` alone only aligns digits of
       equal count, so "88" and "100" would still disagree. */
    /* ---- the site plan, and the wind over it ----
       ★ Inlined SVG, sized by its container. `max-width: 100%` plus `height:
       auto` keeps the drawing inside the card at every width without a media
       query - it has an intrinsic aspect ratio from its viewBox. */
    .site-plan { display: block; width: 100%; height: auto; max-width: 100%;
                 border-radius: 8px; }

    /* ★ **The wind mark: a fixed arrow for direction, a ring for speed.**
       Nothing here scales with wind, so the plan underneath stays legible at
       every speed - which a length-scaled arrow could not do without either
       leaving the frame or compressing its own scale until 25 and 45 mph
       looked alike.

       ⚠️ **Not one color in this block, and that is enforced.** The
       band colors and the cream outline are presentation ATTRIBUTES in
       `_site_plan.html`, which is the color ratchet's one allow-listed
       drawing - a drawing's palette is not the UI's palette. An earlier
       draft of this block carried three literals and three separate
       ratchets caught it: the shared-stylesheet scan, the total-literals
       count, and the token-palette equality. This file styles geometry. */
    /* A value this app decoded from the raw METAR because the API returned
       null for it. Muted, because it is a repair rather than a headline. */
    .chip-note { color: var(--fgColor-muted); font-style: italic; }

    .wind-mark { pointer-events: none; }
    /* ⚠️ **18%, not the 10% the spec asked for, and the difference is
       measured.** Over this drawing's lawn (#C4D29E) a 10% black track
       blends to dE 6.2 - below the ~10 where a band reads as distinct - so
       the ring was invisible and the arc had nothing to be a proportion OF.
       That is the whole reason a gauge beats a scaled arrow, so a track you
       cannot see gives the advantage back. 18% measures dE 11.3 on the lawn
       and 10.1 on the roof.

       ★ The spec's 10% was not wrong, it was untested against this
       background: on white it reads fine, on a mid-tone lawn it does not. */
    .wind-gauge-track { fill: none; stroke-width: 24; stroke-opacity: .18; }
    .wind-gauge-arc { fill: none; stroke-width: 24; stroke-linecap: butt; }
    /* ★ The gust: one tick on the same scale, so the gap between the
       arc's end and the tick IS the gust margin. A fact the text has always
       stated and the picture never showed. The dasharray draws exactly one
       12-unit mark; `stroke-dashoffset` from the template places it. */
    .wind-gauge-gust { fill: none; stroke-width: 34; stroke-linecap: butt;
                       stroke-dasharray: 12 1445.7; }
    /* The gust MARGIN: the span between sustained and gust, faint under the
       tick that caps it. Narrower than the arc so it reads as an extension of
       the reading rather than part of it. */
    .wind-gauge-margin { fill: none; stroke-width: 24; stroke-linecap: butt;
                         stroke-opacity: .30; }
    /* The forecast row's arrow: small, inline with the text, and inheriting
       the muted color of the line it sits on so it reads as punctuation on
       the sentence rather than a second element competing with it. */
    .wx-wind-arrow { vertical-align: -.15em; stroke: currentColor; fill: none;
                     stroke-width: 2; stroke-linecap: round;
                     stroke-linejoin: round; opacity: .75; }
    .wind-arrow-body { stroke-width: 9; stroke-linejoin: round; }
    /* ⚠️ The halo and the capsule carry the SAME animation, or they
       separate mid-drift. The halo is wider and sits under. */
    .wind-streak-halo, .wind-streak {
      stroke-linecap: round;
      animation: wind-drift var(--motion-drift) ease-in-out infinite alternate; }
    .wind-streak-halo { stroke-width: 21; opacity: .7; }
    .wind-streak { stroke-width: 11; opacity: .9; }

    /* ⚠️ **CSS only.** The drawing carries no script - a stated property
       of that file, and there is a script-size budget guard - so a
       JS-driven drift would break both.

       ★ `prefers-reduced-motion` needs nothing here: the global rule
       further down already forces `animation-duration: .01ms !important`, so
       the streaks hold still and the arc and the number carry the reading
       alone. */
    /* ⚠️ **Opacity is NOT animated, and that is the whole point.**
       An earlier version faded the capsules in and out, ending at
       `opacity: 0` - which meant that under `prefers-reduced-motion`, where
       the global rule below forces `animation-duration: .01ms !important`, the
       animation completed instantly and HELD that final frame. The streaks
       were not "held still" as intended; they were permanently invisible.

       ★ Animating transform alone means every frozen state is a visible
       state. `alternate` makes it sway rather than loop, so there is no jump
       back to the start to hide with a fade. Caught by rendering the page and
       looking at it - no test in this suite can see an element that is present,
       correct, and transparent. */
    @keyframes wind-drift {
      from { transform: translateY(22px); }
      to   { transform: translateY(-22px); }
    }

    /* ⚠️ **Calm is a state, not a zero-length arrow.** A station
       reporting `windSpeed 0, windDirection 0` is the calm ENCODING, not a
       wind from the north. */
    .wind-calm-dot { fill: none; stroke-width: 12; }
    .wind-readout { stroke-width: 8; paint-order: stroke; font-size: 56px;
                    font-weight: 700; font-family: inherit; }

    /* ⚠️ The calm state says the word. A zero-length arrow points in an
       arbitrary direction and asserts something false, so there is no arrow to
       style here - only text. */
    .wind-calm { color: var(--fgColor-muted); font-size: .9rem; }

    .wx-figures { display: inline-flex; align-items: center; gap: .5rem; }
    .wx-high { min-width: 3.6ch; text-align: right; }
    /* Secondary to the headline number it sits beside - it is a qualifier, not
       a second forecast. */
    .wx-feels { font-size: .8rem; color: var(--fgColor-muted); }

    /* ---- the reading grid, promoted out of `.wx-grid` ----
       ★ A labeled-value grid is not a weather idea. It stops being named
       after the one page that happened to have it first. Built on <dl>, so a
       reading is a term/description PAIR rather than two unrelated divs. */
    .readings { display: grid; grid-template-columns: repeat(auto-fit, minmax(7.5rem, 1fr));
                gap: .8rem; margin: 0; }
    /* ⛔ **A browser default nobody reset, and it is why the landing page's
       forecast tile read as a zigzag.** `<dd>` carries a UA
       `margin-inline-start: 40px`, and NOTHING in this stylesheet mentioned
       `dd` - so a reading's value sat 40px right of its own label while the
       caption below it sat flush:

           Tonight            <- dt,      flush
                   77°        <- dd.tnum, 40px in
           Mostly Clear       <- dd.meta, flush

       ★ **The pages that looked right looked right by luck.** Two of the three
       classes used on these cells cancel the margin by ACCIDENT:
       `.reading-value` sets `margin: 0`, and `.meta` sets `margin: 0 0 .4rem`
       because it is a paragraph class. `.tnum` sets only
       `font-variant-numeric`, so a `<dd class="tnum">` rendered correctly only
       when somebody also remembered `.reading-value` - and seven cells did not:
       five `.tnum` on `landing.html` and `/ui/weather`, plus **two bare `<dd>`
       carrying no class at all** (`weather.html:519,590`), which the eye missed
       and the guard found.

       ⚠️ Reset on the LIST rather than at each cell. Fixing the five sites
       would leave the next `<dd>` to inherit the same 40px, which is the defect
       and not the instance of it. Measured in a browser on 2026-08-31: every
       `.tnum` cell 40px offset, every `.meta` cell 0. */
    .readings dd { margin-left: 0; }
    .reading { background: var(--bgColor-field); border-radius: 6px; padding: .5rem .7rem; }
    .reading dt { margin: 0; }
    .reading-value { font-size: 1.05rem; margin: 0; }
    .reading-unit { font-size: .75rem; color: var(--fgColor-muted); }
    /* ⚠️ For the two fields with no bound on their length - a task's text and
       its JSON payload. In a 7.5rem track a payload wrapped to a column about
       one word wide, which is worse than the run-on sentence it replaced. */
    .reading-wide { grid-column: 1 / -1; }
    .reading-wide code { word-break: break-all; }
    /* A relay schedule is a name, a status and a set of readings - the same
       shape as a `.rows` item, one level in. */
    .schedule + .schedule { margin-top: .9rem; padding-top: .9rem;
                            border-top: 1px solid var(--borderColor-muted); }
    .schedule .readings { margin-top: .5rem; }
    /* ⚠️ `.readings` is `margin: 0` so it can sit flush inside a tile,
       which meant the scheduler's Run now / Disable / Delete row butted
       straight against the payload cell above it. The gap belongs to whatever
       FOLLOWS the grid, not to the grid. */
    .readings + .row { margin-top: .9rem; }
    /* ⚠️ Same reason as the row above: `.readings` is `margin: 0` so it can sit
       flush in a tile, which left every card's source note butted against the
       bottom of the grid. The gap belongs to what FOLLOWS. */
    .readings + p { margin-top: .9rem; }
    /* ⚠️ Separated from the Send button above it, which it was flush
       against. A secondary link after a primary control needs the gap or the
       two read as one group. */
    .endpoint-permalink { display: inline-block; margin-top: .9rem;
                          font-size: .85rem; color: var(--fgColor-accent); }

    /* ---- alert severity, as a role rather than four classes ----
       ⚠️ `data-severity`, not `.wx-extreme`/`.wx-severe`/`.wx-moderate`/
       `.wx-minor`. Four classes for four values of one attribute is an
       enumeration pretending to be a taxonomy, and nothing stopped a fifth
       value silently matching none of them - the default stripe below is what
       makes an unknown severity still visible. */
    /* ⚠️ A teletype document, rendered as one. The Area Forecast Discussion
       is hard-wrapped at ~70 columns with its own indentation and section
       headers; reflowing it as prose breaks the headers apart. So it keeps its
       own line breaks and scrolls inside its own box rather than making the
       page scroll sideways on a phone. */
    /* ⚠️ The image and its reference frame are stacked, not composited. The
       radar PNG is transparent reflectivity with no geography; the rings and
       the house marker are drawn over it in SVG rather than burned in, so the
       frame stays crisp at any size and costs no server-side image work. */
    .wx-radar { position: relative; max-width: 600px; margin: .5rem 0 0; }
    .wx-radar img {
      display: block; width: 100%; height: auto; border-radius: 6px;
    }
    /* ⛔ **The card carries the ground, not the images.** This was on
       `.wx-radar img`, so it landed on the reference map too - and that layer
       is INVERTED, which turned a dark inset into a light gray rectangle
       covering the whole card. The reflectivity vanished into it and every
       county label sat on mud. Reported from the page; the composite mock had
       no background and never showed it. */
    .wx-radar { background: var(--bgColor-inset); border-radius: 6px; }
    /* The GOES frame. ★ Deliberately NOT sharing `.wx-radar`: that stack is
       three layers - an inverted basemap, transparent reflectivity and an SVG
       overlay - and this is one opaque JPEG. Reusing it would inherit an
       `invert(1)` that has no meaning on full-color imagery.
       ⚠️ The background sits on the container, which is the lesson the radar
       card paid for above. */
    /* ⛔ **No fixed cap here, and the 600px one it replaced was a real defect.**
       It was copied from `.wx-radar`, whose image genuinely is 600px. This
       frame can be 2400, so the cap downloaded 2.7 MB and then threw away
       three quarters of it - the detail control appeared to do nothing.
       Reported from the page. */
    .wx-goes { margin: .5rem 0 0; background: var(--bgColor-inset);
               border-radius: 6px; }
    /* ★ `max-width: 100%` rather than `width: 100%`, with `width`/`height`
       attributes on the element: the picture fills the column when it is bigger
       than it and stops at its OWN size when it is smaller, so the 600px tier
       is never stretched to 1600 and made to look worse than it is. */
    .wx-goes img { display: block; max-width: 100%; height: auto;
                   margin: 0 auto; border-radius: 6px; }
    /* ⚠️ NWS renders the reference map black on transparent, which is
       invisible on a dark card. `invert(1)` flips lines AND labels together;
       the service's own `dynamicLayers` recolor reaches the lines only.
       ⛔ Light mode gets no filter - black on a light card is already right,
       and inverting there would hide it instead. */
    /* ⚠️ .55, not .35. Under a filter that only ever darkens the halo around
       each label, a third of full strength put the county names below the
       reflectivity they are there to locate. Compared at .35, .55 and .75
       against a live echo; .55 reads without competing. */
    .wx-radar-map { position: absolute; inset: 0; opacity: .55; }
    :root:not([data-theme="light"]) .wx-radar-map { filter: invert(1); }
    @media (prefers-color-scheme: light) {
      :root:not([data-theme="dark"]) .wx-radar-map { filter: none; }
    }
    :root[data-theme="dark"] .wx-radar-map { filter: invert(1); }

    .wx-radar-frame {
      position: absolute; inset: 0; width: 100%; height: 100%;
      pointer-events: none;
    }
    .wx-radar-frame circle, .wx-radar-frame line {
      fill: none; stroke: var(--fgColor-subtle); stroke-width: 1; opacity: .45;
    }
    .wx-radar-frame text {
      fill: var(--fgColor-subtle); font-size: 13px; opacity: .7;
    }
    .wx-radar-frame .wx-radar-home {
      fill: var(--fgColor-accent); stroke: none; opacity: 1;
    }
    .wx-radar-frame .wx-radar-north { text-anchor: middle; }

    .wx-narrative {
      white-space: pre-wrap;
      overflow-x: auto;
      font-size: .82rem;
      line-height: 1.45;
      color: var(--fgColor-muted);
      margin: .5rem 0 0;
    }

    .wx-alert { border-left: 4px solid var(--fgColor-subtle); }
    .wx-alert[data-severity="extreme"],
    .wx-alert[data-severity="severe"] { border-left-color: var(--borderColor-danger); }
    .wx-alert[data-severity="moderate"] { border-left-color: var(--fgColor-attention-emphasis); }
    .wx-alert[data-severity="minor"] { border-left-color: var(--fgColor-accent); }

    /* The creek bands. ⚠️ `data-creek` rather than three classes, for the
       reason recorded against `data-severity` above: an enumeration of one
       attribute's values is not a taxonomy, and a fourth band arriving with no
       rule would render as a plain badge rather than as nothing.
       ★ Only the three RISING bands are painted. `steady` and `falling` are
       reassuring states and get the plain chip - coloring them would make a
       creek behaving normally look like a thing to look at. */
    .badge[data-creek="rising"] { background: var(--bgColor-attention-muted);
                                  color: var(--fgColor-attention); }
    .badge[data-creek="fast"] { background: var(--bgColor-attention-muted);
                                color: var(--fgColor-attention);
                                border: 1px solid var(--borderColor-attention); }
    .badge[data-creek="rapid"] { background: var(--bgColor-danger-muted);
                                 color: var(--fgColor-danger);
                                 border: 1px solid var(--borderColor-danger); }

    /* The drought bands, on the USDM's own five-step scale. ⚠️ `none` and `d0`
       are deliberately unpainted: D0 is "abnormally dry", the pre-drought watch
       category, and coloring it would report a dry spell as a drought. */
    .badge[data-drought="d1"] { background: var(--bgColor-attention-muted);
                                color: var(--fgColor-attention); }
    .badge[data-drought="d2"] { background: var(--bgColor-attention-muted);
                                color: var(--fgColor-attention);
                                border: 1px solid var(--borderColor-attention); }
    .badge[data-drought="d3"],
    .badge[data-drought="d4"] { background: var(--bgColor-danger-muted);
                                color: var(--fgColor-danger);
                                border: 1px solid var(--borderColor-danger); }
    /* ★ Only `worsening` is painted. "Easing" and "steady" are the states you
       want to see, and coloring good news trains the eye to skip the color.
       ⚠️ On the VALUE, not on a badge: a badge inside a `.reading` tile is a
       control-sized object in a place that holds numbers, and it made the row
       of tiles ragged. */
    .reading-value[data-trend="worsening"] { color: var(--fgColor-attention); }
    /* The credential inventory. ⚠️ Only ranks 4 and 5 are painted: those are
       the ones that mean "rotate this first", and coloring a harmless public
       client id would train the eye past the two that matter. */
    .badge[data-risk="5"] { background: var(--bgColor-danger-muted);
                            color: var(--fgColor-danger);
                            border: 1px solid var(--borderColor-danger); }
    .badge[data-risk="4"] { background: var(--bgColor-attention-muted);
                            color: var(--fgColor-attention); }
    .badge[data-cred="failing"] { background: var(--bgColor-danger-muted);
                                  color: var(--fgColor-danger); }
    /* ⛔ The same pairing as `.badge.on` above - 1.46:1 in light theme - on the
       "OK" badge beside every working credential. Found by
       `test_guards_contrast.py`, not by the design review, which never captured
       this page. That is the argument for a structural rule over a fix. */
    .badge[data-cred="ok"] { background: var(--bgColor-success-emphasis);
                             color: var(--fgColor-onEmphasis); }
    /* ⭐ The cadence verdict beside the status (`credential_use.verdict`): `ok` and `failing` reuse the pairings above, `overdue` is the attention pair, and `rarely` is deliberately plain, because a credential used
       twice a year is not news. ⚠️ Same pairings as the badges around it, so `test_guards_contrast.py` has nothing new to measure. */
    .badge[data-cred-verdict="ok"] { background: var(--bgColor-success-emphasis);
                                     color: var(--fgColor-onEmphasis); }
    .badge[data-cred-verdict="failing"] { background: var(--bgColor-danger-muted);
                                          color: var(--fgColor-danger); }
    .badge[data-cred-verdict="overdue"] { background: var(--bgColor-attention-muted);
                                          color: var(--fgColor-attention); }
    /* ★ **A list of disclosures that reads as a table.** The header and every
       summary share one `grid-template-columns`, which is what aligns the
       columns; a real `<table>` cannot hold a `<details>` without fighting it.
       ⚠️ `minmax(0, …)` on the first column, or a long credential name refuses
       to shrink and pushes the whole grid wider than the card. */
    .cred-table { border: 1px solid var(--borderColor-default);
                  border-radius: 10px; overflow: hidden; }
    .cred-head, .cred-summary {
      display: grid; align-items: center; gap: .75rem;
      grid-template-columns: minmax(0, 2.2fr) minmax(0, 1fr) 3.5rem
                             minmax(0, 1fr) minmax(0, 1fr) 5rem;
      padding: .6rem .9rem;
    }
    .cred-head { background: var(--bgColor-field); font-size: .8rem;
                 color: var(--fgColor-muted); }
    .cred-col { color: inherit; text-decoration: none; }
    .cred-col:hover { text-decoration: underline; }
    /* The sorted column names itself rather than relying on an arrow glyph,
       which is one more thing to get right in both themes. */
    .cred-col.is-sorted { color: var(--fgColor-accent); font-weight: 600; }
    .cred-item { border-top: 1px solid var(--borderColor-muted); }
    /* ⚠️ `list-item` is the default `display` of a summary in some engines and
       renders a marker on top of the grid. */
    .cred-summary { cursor: pointer; list-style: none; }
    .cred-summary::-webkit-details-marker { display: none; }
    .cred-summary:hover { background: var(--bgColor-field); }
    .cred-item[open] .cred-summary { background: var(--bgColor-field); }
    .cred-name { font-weight: 600; }
    .cred-service, .cred-usage, .cred-used { font-size: .85rem;
                                             color: var(--fgColor-muted); }
    /* ⚠️ Truncate rather than wrap: one wrapped cell would make that row taller
       than every other and break the alignment the grid exists for. */
    .cred-name, .cred-service, .cred-usage, .cred-used {
      overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
    }
    .cred-detail { padding: 0 .9rem 1rem; }
    .cred-blast { margin-top: .9rem; }

    /* ⚠️ Six columns need room. Below that the grid becomes two rows of
       stacked labels, which is unreadable - so the middle columns drop and the
       detail pane carries them instead. */
    /* ⛔⛔ **50rem, and it is a SECOND breakpoint rather than a move of the
       first.** Promotion and the word/glyph switch are different questions and
       were briefly conflated, which cost a regression: with House in the bar,
       measured 2026-09-01, the header wrapped to **94-117px between 720 and
       760px** - words showing that did not fit. Below 720 the glyphs saved it
       and above 780 the words fit, so the fault lived in a 60px band nobody
       would think to look at.

       ★ Moving the single breakpoint to 50rem would have fixed the wrap by
       giving 704-800px the *glyph* bar, which is a worse answer: those widths
       have room for words, just not for ten of them. So words still appear at
       44rem and only PROMOTION waits for 50rem. Three zones, each one row:

         < 44rem   glyphs, everything in the panel
         44-50rem  words, House still in the panel   ← the band this fixes
         >= 50rem  words, House laid out in the bar

       ⚠️ 50rem = 800px against a measured 780. Clear of the observation rather
       than tuned to it, per principle 8. */
    /* ⛔ **Was `(max-width: 49.99rem)` against `(min-width: 50rem)` above** -
       799.84px and 800px, so any width between them matched NEITHER half. That
       is the `.98`-subtraction idiom G-6 exists to reject, and G-6 could not
       see it because its pattern matched `px` only while every breakpoint in
       this file is in `rem`. Range syntax with one identical bound cannot leave
       a gap; `_doc_style.html` already used it. */
    @media (width < 50rem) {
      .nav-extra {
        display: none; position: absolute; z-index: 20; top: 100%; right: .5rem;
        min-width: 12rem; flex-direction: column; gap: .15rem;
        background: var(--bgColor-layer1); border: 1px solid var(--borderColor-default);
        border-radius: 10px; padding: .5rem; box-shadow: 0 6px 20px rgba(0,0,0,.45);
      }
      .nav-extra.is-open { display: flex; }
      .nav-grp-label {
        display: block; margin: .35rem 0 .1rem; font-size: .75rem;
        letter-spacing: .06em; text-transform: uppercase;
        color: var(--fgColor-subtle);
      }
      .nav-extra > .nav-grp-label:first-child { margin-top: 0; }
      /* ★ In the dropdown the sections read as a list, so the glyph is
         redundant beside a word that is now visible. */
      .nav-extra .nav-glyph { display: none; }
      .nav-extra .nav-word {
        position: static; width: auto; height: auto; clip-path: none;
      }
      /* ★ The marker follows the links: while House is in the panel, `More` is
         genuinely the section for a promoted page. */
      .nav-more-toggle.is-section-narrow { color: var(--fgColor-accent); }
    }

    @media (max-width: 44rem) {
      /* Hidden by translating rather than by `display`, so focus and layout
         are preserved: nothing reflows and the bar stays reachable. */
      .site-head[data-hidden] { transform: translateY(-100%); }
      /* ⛔ Focus beats scroll. Tabbing into a hidden bar must reveal it, or
         the navigation is unreachable without a pointing device. */
      .site-head:focus-within { transform: none; }
      /* ★ Glyphs replace words only here. The word stays in the DOM as the
         accessible name - `visually-hidden` rather than `display: none` - so a
         screen reader still reads "Weather" while the eye sees a sun. */
      .nav-glyph { display: inline; }
      /* ⛔⛔ **A glyph cell is a fixed 44 x 44px, never "padding plus the
         emoji"** (2026-09-24). Width used to be `.6rem` of padding each side
         plus the glyph, and the glyph's width is the EMOJI FONT's: ~17px in
         Windows' Segoe, ~26px in Apple Color Emoji. So every render from the
         Windows desk undercounted the iPhone by ~9px a cell, and on the
         household's iPhone mini with Display Zoom (a 320px viewport, which is
         staying) the bar overflowed and ☰ wrapped to a second line with ⚙
         hanging halfway down. Fixed cells make the bar's width a property of
         THIS file rather than of the font, so a 320px render from here now
         predicts the phone.
         ★ `>` so only the bar's own items: the ☰ panel's links are `header nav
         a` too, and they show words that must not be squeezed into 44px.
         ★ The budget at 320px: header padding 32 + mark 40 + four 44px cells
         176 + nav-to-settings gap 8 + ⚙ 44 = 300, so 20px spare. That is why
         the gap between cells is 0: each cell is already a whole 44px tap
         target, so a gap bought nothing but width. With the old .25rem gaps it
         was 316, only 4px spare. A fifth glyph (another 44) would not fit at
         320: the answer then is moving a section into ☰, not shrinking cells
         below the tap target. `test_header_fits_320` holds the arithmetic. */
      header nav { gap: 0; }
      header nav > a:not(.nav-home), header nav > .nav-more-toggle {
        width: var(--control-height); padding: 0; justify-content: center;
      }
      /* ★ The caret is the APP's own `::after`, not the browser's marker -
         which is why `list-style: none` did nothing to it. It is redundant
         beside ☰ and ⚙ (both already read as "opens something") and costs
         ~12px the glyph row cannot spare. */
      .nav-more summary::after { content: ""; }
      /* ⛔ `min-width: 0` so the nav WRAPS instead of overflowing. Without it a
         flex item refuses to shrink below its content, so seven glyphs pushed
         the settings control off the right edge - present in the DOM, keyboard
         reachable, and invisible. A control that exists and cannot be seen is
         worse than one that is absent, because nothing says it is missing. */
      /* ⛔ **It does not fit, and the arithmetic says so.** Seven 44px targets
         plus the mark plus the settings control is ~414px against a 390px
         phone. `min-width: 0` does not help - the nav's own items will not
         shrink below their tap targets, and they should not: a 44px target is
         worth more than a single row.
         ★ So the HEADER wraps rather than the settings control being pushed
         off the edge. It wraps only when it must, and the auto-hiding bar makes
         a second row cost scroll distance rather than permanent height - which
         is the same bargain that made pinning acceptable in the first place. */
      .nav-word {
        position: absolute; width: 1px; height: 1px; overflow: hidden;
        clip-path: inset(50%); white-space: nowrap;
      }
      .cred-head, .cred-summary { grid-template-columns: minmax(0, 1fr) 2.75rem 5rem; }
      /* ⛔ **The header and the body disagreed about the shape of the table.**
         This hid `.cred-service`, `.cred-usage` and `.cred-used` - which are
         BODY classes. The six header links all share one class, `.cred-col`, so
         none of them hid: the grid dropped to three columns while the header
         still rendered six labels, which then wrapped onto two lines inside it.
         Captured at 390px, and the effect is that you cannot tell which column
         a badge sits in.

         ★ One marker class worn by both halves of every column that hides, so
         the header cannot fall out of step with the body again.
         `test_guards_credentials_table` asserts the two counts are equal. */
      .cred-hide-narrow { display: none; }
      /* ⭐ **On a phone the name WRAPS, and that overrides "truncate rather than
         wrap" above** (plan review P-G, closed 2026-09-24). At 320px the name was
         cut after ~11 characters - "Relay auth…", "Discord ale…", "Discord we…" -
         while Risk and Status kept their full width. The name is the one column
         that identifies the row, so a taller row is the cheaper cost here;
         alignment across rows still holds, because the other two columns are
         fixed-width. Risk is a one-digit badge, so its column gave up 0.75rem
         to the name. */
      .cred-name { white-space: normal; overflow-wrap: break-word; }
    }
    /* The drought headline, which is a sentence rather than a reading. It leads
       the card because "moderate drought over 68% of the county" is prose, and
       putting it in a 7.5rem `.readings` column wrapped it across four lines
       mid-phrase. */
    .wx-drought-lead { margin: 0 0 .9rem; font-size: 1.05rem; line-height: 1.5; }
    .wx-drought-lead .badge { margin-left: 0; }

    /* A heading that opens a card needs no top margin; three pages were
       setting `style="margin-top: 0"` by hand. */
    .card > h2:first-child { margin-top: 0; }

    /* A link that looks like a button. ⚠️ Replaces `<a><button>`, which is
       invalid (interactive content inside a link) and announces twice. */
    /* ⛔ **`Sync again`, `Channel −` and `Channel +` each broke across two
       lines**, captured at 390px. A two-line button reads as two controls, and
       the second line is usually a single word - a symbol, in the channel
       stepper's case, which is worse than useless on its own.

       ★ Same argument as `.badge`, which took this rule for the same reason:
       a label short enough to be a button is short enough to stay on one line.
       ⚠️ It moves the give from inside the button to the row around it, so
       any `.row` holding buttons and prose needs `wrap` - which is what the
       two rows carrying these three buttons now say. */
    .btn { white-space: nowrap; display: inline-flex; align-items: center; justify-content: center;
           min-height: var(--control-height); padding: .4rem .9rem;
           background: var(--bgColor-accent-emphasis); color: var(--fgColor-onEmphasis);
           border-radius: 6px; text-decoration: none; font-size: .9rem; }
    .btn:hover { opacity: .85; }

    /* ★ **De-qualified from `button.off`, and its POSITION is load-bearing.**

       It was element-qualified, which is what made
       `<span class="badge off">FAILING</span>` render as an ordinary gray
       badge - the span matched nothing. `.off` now means "not the primary
       action" on whatever carries it.

       ⚠️ **It must stay after `.btn`.** Both are one class - specificity
       (0,1,0) - so the later rule wins, and `.btn` sets the accent background
       this exists to override. `<a class="btn off">` would render as a primary
       button if this moved up the file. That fragility is the real cost of
       de-qualifying rather than adding `.btn.off`, which at (0,2,0) would have
       won wherever it sat. `test_plan_guards.py` asserts the order rather than
       leaving it to whoever next tidies this file. */
    .off { background: var(--bgColor-neutral-emphasis); }

    /* ★ **An explicit accent, and it exists for the ratchet rather than for the
       pixels.**

       ⛔ `button { background: var(--bgColor-accent-emphasis) }` above means
       emphasis in this app is **opt-out**: measured 2026-09-01, **45 controls
       are accent because nobody wrote `.off`**, against 50 that are quiet
       because somebody did. That is why the accent accumulated until
       `/ui/hue-scenes` rendered 186 of them down a 17-screen page - the
       vocabulary says accent is "the one thing this page exists to do", and the
       stylesheet says it is "any button unless someone remembered".

       ⚠️ **Inverting the default was the wrong fix for one slice.** It would
       silently change all 45 on pages this review has not captured, which is
       exactly the unevidenced sweep §0 warns against. So the accent becomes
       DECLARABLE, `test_guards_emphasis.py` ratchets the opt-out count
       downward, and when only the deliberate ones remain the default can be
       flipped without touching a template.

       ★ Identical to the default today on purpose: this changes nothing you can
       see, which is what makes it safe to add now. */
    .primary { background: var(--bgColor-accent-emphasis); color: var(--fgColor-onEmphasis); }

    /* ⛔ **D6: one primitive produced two compositions, decided by how long
       somebody made the heading.** At 390px `Latest events` + `Full feed` fit on
       one line and read as designed, while `Next couple of days` was too long -
       so `Full forecast` dropped to its own line, left-aligned, landing between
       the heading and the data it belongs to.

       ★ **Let the HEADING wrap instead of the row.** `nowrap` on the container
       plus `min-width: 0` on the h2 and `flex-shrink: 0` on the action keeps one
       composition at every width - heading left, action right - and costs no
       extra height, because the heading was going to use those lines anyway.
       ⚠️ The alternative, stacking at narrow width, is deterministic too but
       spends a line on every card that currently fits. */
    .page-head { display: flex; align-items: baseline;
                 justify-content: space-between; gap: 1rem;
                 flex-wrap: nowrap; }
    .page-head > :first-child { min-width: 0; }
    .page-head > .btn, .page-head > a.btn { flex-shrink: 0; }

    /* ★ The Roku page was the one that actually wasted a desktop: `.remote` is
       capped at 16rem and every TV was a full-width card, so three TVs meant
       ~1300px of scroll for a 256px control.

       ⚠️ `auto-fit` with a 17rem floor and NO breakpoint. The number that
       decides the column count is the card's own minimum width, which is the
       fact that actually matters; a media query would have been a second copy
       of it, guessed. One column below ~35rem, three inside the 900px
       `--measure-wide` cap. */
    /* ⚠️ 1.5rem, not the 1rem this shipped with. Three remotes side by side
       read as one dense control surface at 1rem - each card is a grid of
       buttons and the gap between cards was the same as the gap between the
       buttons inside them, so the boundary that matters (which TV am I
       pressing?) was the least visible one on the page. */
    .device-grid { display: grid; gap: 1.5rem;
                   grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr)); }
    /* ⚠️ The cards inside carry their own margin from `.card`; the grid owns
       the spacing now, so that margin would double every gap.

       ★ **And once they are zeroed the GRID has to own its outer spacing too**
       - which it did not, so it sat flush against the toolbar above it. The
       first column looked fine only because the element above it happened to
       end with a margin; the other two touched the buttons. Reported from the
       deployed page, and invisible to every guard in the suite: element
       balance, id references and the class audit all pass over a layout that
       has no gap in it. */
    .device-grid { margin: 1.25rem 0; }
    .device-grid > .card { margin: 0; }

    /* The event feed's filter row, no longer boxed in a `.card` with 200 rows
       after it. It keeps the card's own bottom spacing so the feed does not
       start against it. */
    .chips-bar { margin-bottom: 1rem; }
    /* ⚠️ `kind` demoted from a filled `.badge` to subtle text. 200 badges per
       page made `.badge` ordinary and the alert badge stopped reading as
       exceptional; this is the fact moving to the weight it deserves, not
       disappearing. */
    .event-kind { color: var(--fgColor-subtle); }
    details summary { cursor: pointer; }
    /* ⚠️ An `<h2>` inside a `<summary>`: the heading has to stop being a block
       or it wraps to its own line and the disclosure marker sits above it. The
       heading keeps its semantics, which is what puts the create form in the
       document outline. */
    /* ⚠️ **`display: flex` on a `<summary>` removes its disclosure
       triangle**, so this shipped as a heading with no affordance at all -
       you had to already know it opened. `.doc-toc > summary` hit the same
       thing and solved it with an explicit chevron; same fix, same shape, so
       the two disclosures in this app behave alike.

       The chevron is `::after` with `margin-left: auto`, which parks it at
       the right edge of the row rather than against the heading. */
    .create-task > summary { min-height: var(--control-height); display: flex;
                             align-items: center; gap: .4rem;
                             cursor: pointer; list-style: none; }
    .create-task > summary::-webkit-details-marker { display: none; }
    .create-task > summary::after { content: " ▾"; margin-left: auto;
                                    color: var(--fgColor-muted); }
    .create-task[open] > summary::after { content: " ▴"; }
    .create-task > summary h2 { display: inline; margin: 0; }

    /* ⚠️ Honored, not assumed. Three real durations exist in this app - the
       1500ms Roku settle, the brightness debounce and hold-to-repeat - and
       "reduce" is a request from someone for whom motion is a symptom, not a
       preference about polish. */
    /* ★ **G-8.** Under Windows High Contrast the UA replaces `outline-color`
       with a color of its own and drops `box-shadow` entirely, so
       `var(--fgColor-accent)` above simply stops applying and the ring can land
       the same color as the text it surrounds.

       ⚠️ `Highlight` is a CSS SYSTEM color, and that is the whole point: a
       token or a hex written here would be discarded by the same mechanism
       that discarded the accent. It is guaranteed distinct from `CanvasText`.

       ⚠️ `forced-color-adjust` is used in **exactly one place**, and the
       claim this comment used to make is no longer true. It said "this app has
       no element whose meaning is carried by color alone - every badge
       carries its state as text". That holds for every control and every
       badge, and it stops holding at the site plan.

       ★ **The drawing is a picture, not chrome.** Under forced colors
       every fill is replaced, so a 68-shape architectural drawing collapses
       into one color and stops being a drawing at all - roof, lawn, water and
       paving become indistinguishable. Opting it out is not overriding the
       reader's choice about the interface; it is declining to destroy an
       image. The wind arrow on top needs no exemption: its meaning is geometry
       plus the text label beside it.

       ⚠️ So the rule stands for everything else. If a second use of this
       property ever appears, the thing to check is whether it is genuinely a
       picture or whether someone is exempting UI to avoid thinking about
       contrast. */
    /* ---- print ----------------------------------------------------------
       ★ **Argued on its own, because the borrowed justification was false.**
       The design document's revision 1 claimed "both USWDS and Polaris treat
       print as a first-class medium for reference content"; neither ships a
       print stylesheet. What IS true is local: `/ui/spec` and `/ui/backlog`
       render two long reference documents, and printing or PDF-ing a spec is
       a thing people do with a spec.

       ⚠️ **This is the least verifiable change in the whole plan.** There is
       no browser, no `page.pdf()` and no CDP in this project, and the
       screenshot tooling renders the SCREEN medium. Every assertion below is
       textual: that the rules exist, that their selectors resolve, and that
       the cascade arithmetic works out. None of it is evidence that the page
       looks right on paper. That check is a human with Ctrl+P.

       ⚠️ **One block, and it has to be here rather than in `_doc_style.html`,**
       because that file is included only by the two doc pages while this one
       is on every page. The cost is that `_doc_style.html` loads AFTER this
       file, so its `.md .table-scroll` beats an equal-specificity rule written
       here on source order alone - hence the one `!important` below, marked. */
    @media print {
      /* ⚠️ **The cascade defect the document caught, and it is a repeat.**
         `_tokens.css` sets `:root[data-theme="dark"] { color-scheme: dark }` at
         (0,2,0), and a media query adds NO specificity - so `:root
         { color-scheme: light }` inside `@media print` loses, and anyone who
         chose Dark prints dark. Attribute parity fixes it: (0,2,0) against
         (0,2,0), later in source order, because this file loads after the
         tokens. The identical mistake is already documented at
         `_doc_style.html:51-56`, which is why it is worth a comment here.

         ⚠️ On ONE line: `test_only_root_sets_color_scheme` scans line-by-line
         and requires `:root` on the same line as the declaration. */
      :root, :root[data-theme] { color-scheme: light; }
      /* ⚠️ And the `@supports` fallback path needs the colors too. In a
         browser without `light-dark()`, `:root` still holds the DARK literals
         and print strips backgrounds - leaving light gray text on white paper
         at roughly 1.3:1.

         ★ `Canvas`/`CanvasText`, not `white`/`black`, and the named-color
         guard was right to reject those. System colors resolve against the
         `color-scheme: light` set one line above, so they are light on paper -
         and unlike two literals they still honor a reader who has configured
         their own. The same idiom the `forced-colors` block below uses, for
         the same reason: these are the colors the UA controls. */
      body { background: Canvas; color: CanvasText; }

      /* The reviewed hidden set: navigation, keyboard affordances, and
         controls. Nothing that carries content. */
      header, .skip-link, .doc-nav, .row-filter, #endpoint-filter,
      .ui-alert { display: none; }

      /* Reclaim the rail's 210px gutter and the screen measure. */
      .doc-layout { display: block; }
      main { max-width: none; padding: 0; }

      /* ★ **The actual print defect, which revision 1's snippet missed.**
         `.md .table-scroll { overflow-x: auto }` CLIPS on paper rather than
         scrolling, so every table wider than its card silently loses its
         right-hand columns - on the two most table-heavy pages in the app.
         ⚠️ `!important` because `_doc_style.html` loads after this file; see
         the block comment. */
      .md .table-scroll { overflow: visible !important; }

      /* The dial cards (`/ui/dial-cards`): four to a page, two across and two down, none split. */
      .dial-cards { grid-template-columns: 1fr 1fr; gap: .5cm; }
      /* The Print button is not on the paper it prints (page map batch H). */
      [data-action="print-page"] { display: none; }
      .dial-card { break-inside: avoid; min-height: 11cm; }
      thead { display: table-header-group; }
      /* ⚠️ Rows and code blocks, NOT tables. Several BACKLOG tables are longer
         than a page, and `break-inside: avoid` on those would push a whole
         table onto a fresh sheet and then overflow it anyway. */
      tr, pre, .reading { break-inside: avoid; }

      /* ⚠️ `:not([href^="#"])`, not `[href^="http"]`. These documents contain
         ZERO absolute links - they are `#anchors` and `/ui/…` paths - so the
         conventional rule would have printed nothing at all. */
      .md a[href]:not([href^="#"])::after { content: " (" attr(href) ")";
                                            font-size: .85em; }

      /* ⚠️ No `@page` margin boxes for running headers. They are Chrome 131+
         only and they stack with the browser's own header and footer, so the
         common case is two headers rather than one. */
    }

    @media (forced-colors: active) {
      :focus-visible, .dpad:focus-visible { outline-color: Highlight; }
      /* The one exemption, argued above. */
      .site-plan { forced-color-adjust: none; }
    }

    @media (prefers-reduced-motion: reduce) {
      *, *::before, *::after {
        animation-duration: .01ms !important;
        animation-iteration-count: 1 !important;
        transition-duration: .01ms !important;
        scroll-behavior: auto !important;
      }
    }

    /* ---- the voice picker -------------------------------------------------
       ⛔ **`input, select, textarea` at :222 sets `width: 100%` and a 44px
       min-height, which applies to RADIOS too.** On a phone that rendered each
       radio as a ~60px circle filling its row, with the label wrapped beneath
       it — 39 voices, unusable. The rule is right for text fields and wrong for
       a radio, so this scopes an override rather than weakening it globally.

       ★ Chips rather than a vertical list. Thirty voices down a column is a
       scroll; wrapped chips are four or five rows, and the tap target is the
       whole chip rather than the dot. */
    .voice-columns { display: grid; grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr));
                     gap: .7rem; margin-bottom: .6rem; }
    .voice-col { border: 1px solid var(--borderColor-default); border-radius: 6px;
                 padding: .5rem .6rem .6rem; margin: 0; }
    .voice-col legend { font-size: .75rem; color: var(--fgColor-muted); padding: 0 .3rem; }
    .voice-chips { display: flex; flex-wrap: wrap; gap: .3rem; }
    .voice-chip { display: inline-flex; }
    /* The input is the state; the span is the control. Kept in the a11y tree
       rather than `display:none`, which would drop it from keyboard order. */
    .voice-chip input { position: absolute; opacity: 0; width: 1px; height: 1px;
                        min-height: 0; margin: 0; pointer-events: none; }
    .voice-chip span { display: inline-flex; align-items: center; gap: .25rem;
                       padding: .3rem .55rem; border-radius: 999px; cursor: pointer;
                       border: 1px solid var(--borderColor-muted);
                       background: var(--bgColor-field); color: var(--fgColor-muted);
                       font-size: .8rem; line-height: 1.2; white-space: nowrap; }
    .voice-chip input:checked + span { background: var(--bgColor-accent-emphasis);
                       border-color: var(--bgColor-accent-emphasis);
                       color: var(--fgColor-onEmphasis); font-weight: 600; }
    .voice-chip input:focus-visible + span { outline: 2px solid var(--fgColor-accent);
                       outline-offset: 2px; }
    .voice-chip .vmeta { font-size: .7rem; opacity: .75; }

    /* ⚠️ The catalog and policy rows put a long voice name and a figure on one
       line, which overflows a 390px viewport. Wrap instead of scroll: these are
       reference, and a horizontal scrollbar inside a card reads as breakage. */
    .voices-ref .row-head { flex-wrap: wrap; gap: .15rem .5rem; }
    .voices-ref .row-name { min-width: 0; overflow-wrap: anywhere; }
    .voices-ref summary { cursor: pointer; }

    /* ---- the alerts page ----
       One row per control: a label, the input, and the declared value beside it.
       The declared value is not a tooltip on purpose - the whole argument for
       making these editable was that reading them required opening five files, and
       hiding the default behind a hover would put it back out of sight. */
    .alert-row {
      display: flex;
      align-items: baseline;
      flex-wrap: wrap;
      gap: .5rem;
      padding: .35rem 0;
    }
    .alert-row label,
    .alert-row .alert-label {
      min-width: 6rem;
      font-weight: 600;
    }
    .alert-row input[type="number"] { width: 7rem; }
    .alert-emitter h3 { margin-bottom: .1rem; }

    /* ---- a routing ladder ----
       Rungs stack, because each is a sentence - "under N seconds, go here" -
       and reading them top to bottom IS the escalation. Laying them out in a
       row would put the thresholds beside each other and hide the ordering
       that makes a ladder a ladder.

       ⚠️ `.alert-ladder` sets `flex-direction: column` on a `.alert-row`,
       which is otherwise a horizontal flex container. Both classes are on the
       element deliberately: the row supplies the padding and gap, this
       supplies the axis. */
    .alert-ladder { flex-direction: column; align-items: stretch; }
    .alert-rung {
      display: flex;
      align-items: baseline;
      flex-wrap: wrap;
      gap: .5rem;
      /* Indented so a rung reads as belonging to the ladder above it rather
         than as another control on the emitter. */
      padding-left: 1rem;
    }
    .alert-rung label,
    .alert-rung .alert-label { min-width: 5rem; font-weight: 600; }

    /* ---- the paste-a-link row ----
       ⚠️ The input has to be allowed to SHRINK, not just grow: a flex item's
       default `min-width: auto` is its content width, so a long pasted URL
       would push the button off a phone screen rather than scrolling inside
       the field. */
    .paste-row { flex-wrap: wrap; }
    .paste-row input { flex: 1 1 18rem; min-width: 0; }
/* The floor plan's viewport clips and the stage moves. Keeping the transform on
   an inner element lets pan, zoom and rotation compose without fighting a
   scroll container.

   ⚠️ The transform is composed HERE from custom properties the page sets.
   Writing `stage.style.transform` from JavaScript would be styling debt in a
   place the `style=` ratchet cannot see - see test_guards_javascript. */
/* ⭐ Forget is the LAST control on a parcel or order row, apart from the everyday ones (page map batch E, s-polish, 2026-10-25): it deletes the record, so it takes the free space before it instead of sitting one tap from Refresh. */
.row > button.danger[data-action^="forget-"] { margin-left: auto; }
/* A grocery row's buttons are a content-sized group, where `auto` has no free space to take: a fixed gap sets Remove apart from Bought instead (page map batch E, r-confirm). */
.row > button.danger[data-action="forget-item"] { margin-left: 1rem; }
.floor-plan-viewport { overflow: hidden; height: 70vh; min-height: 20rem;
  touch-action: pan-y pinch-zoom; cursor: grab; }
/* ⭐ `touch-action: none` trapped a phone's page scroll under a 70vh drawing (page map batch B, d-floor). A finger scrolls the page by default; "Move the plan" (`.is-moving`) turns the scroll off so a drag moves the plan. */
.floor-plan-viewport.is-moving { touch-action: none; }
.floor-plan-viewport.is-dragging { cursor: grabbing; }
/* The shown floor and the move toggle say they are pressed with the attribute a screen reader is given (the kiosk posture buttons' own rule). */
.floor-picker button[aria-pressed="true"] { border-color: var(--fgColor-accent, currentColor); font-weight: 600; }
/* ⭐ **The rotation editor's slide table STACKS below 50rem** (page map batch G, ad-displays, 2026-10-25): four columns of inputs and selects do not fit a phone, and a horizontal scroll over text boxes is the worse of the two answers (`.table-scroll` suits a table you only read). Each row becomes a block; the cell's name is printed above it (by position, so a row built in the browser from the template carries it too); the header row is hidden from sight but kept for a screen reader. */
@media (max-width: 50rem) {
  #slide-table thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
  #slide-table, #slide-table tbody, #slide-table tr, #slide-table td { display: block; width: 100%; }
  #slide-table tr { padding: .6rem 0; border-bottom: 1px solid var(--borderColor-muted); }
  #slide-table td { padding: .25rem 0; }
  #slide-table td::before { display: block; margin-bottom: .2rem; font-size: .85em; color: var(--fgColor-muted); }
  #slide-table td:nth-child(1)::before { content: "Slide"; }
  #slide-table td:nth-child(2)::before { content: "Pages"; }
  #slide-table td:nth-child(3)::before { content: "Refresh and timing"; }
}
/* ⛔ A CLASS, not a `style.display` write - `test_guards_javascript.py`
   forbids the latter, and an SVG <g> has no `hidden` attribute to use
   instead. The toggle flips this one class on the stage and both floors'
   layers follow, so switching floors cannot resurrect a hidden layer. */
.floor-plan-stage.devices-off .device-layer { display: none; }
.floor-plan-stage { transform-origin: 50% 50%; will-change: transform;
  transform: translate(var(--pan-x, 0px), var(--pan-y, 0px))
             scale(var(--zoom, 1)) rotate(var(--turn, 0deg)); }
.floor-plan-stage svg { width: 100%; height: 70vh; display: block; }
/* ⚠️ A room is hoverable only because the SVG is INLINE. An img could not do
   this, which is why the drawing is embedded rather than linked. */
.floor-plan-stage polygon[data-room] { cursor: help; }
.floor-plan-stage polygon[data-room]:hover { filter: brightness(0.94); }
.floor-plan-note { width: 100%; box-sizing: border-box; }

/* A line chart, drawn as inline SVG because this app has no build step.
   ⚠️ Every color is a token, so both themes are covered by the token block
   rather than by a second set of rules here.

   ⛔ `preserveAspectRatio="none"` plus `vector-effect: non-scaling-stroke` is
   the whole technique, and the two are a pair: stretching the 600x140 viewBox
   to whatever width the card is would scale the stroke with it - thicker on a
   desktop card than a phone, and thicker horizontally than vertically because
   the two axes stretch by different factors. `non-scaling-stroke` pins the line
   to 2 real pixels while the geometry stretches. Without it the chart looks
   subtly wrong in a way that is hard to name from a screenshot. */
.chart { margin-top: 0.85rem; }
/* ⚠️ **8rem, raised from 5rem after looking at a render of real data.** The
   viewBox is 600x140 and `preserveAspectRatio="none"` stretches it to the card
   width, so on a 1440px desktop a 5rem box was about 18:1 - and an overnight
   reserve trough from 22,925 MW down to 5,566 MW, a four-fold swing, read as a
   gentle wave. A chart that under-states its own signal is the quieter half of
   the same problem as an axis that over-states it. */
.chart-plot { display: block; width: 100%; height: 8rem; }

/* ⭐⭐ A camera tile. `aspect-ratio` matches the thumbnail's own 640x360, so
   the tile holds its shape before the image loads and the page does not jump
   when a frame arrives. ⚠️ `object-fit: cover` rather than `contain`: a
   letterboxed camera frame reads as a broken image, where a slight crop reads
   as a camera. */
.camera-frame { display: block; margin: 0.5rem 0; border-radius: 6px;
  overflow: hidden; background: var(--surface-sunken); aspect-ratio: 16 / 9; }
.camera-frame img { display: block; width: 100%; height: 100%;
  object-fit: cover; }
/* ⭐ A frame the page fills in after load (`_camera_live.html`) starts hidden, and a tile with no picture yet draws nothing rather than a broken-image icon. */
.camera-frame.is-hidden { display: none; }
.camera-tile-main:not([src]) { visibility: hidden; }
/* ⭐ The landing grid, and the Cameras slide clipped to it: as many columns as
   fit, so four frames make a 2x2 on a wall and a column on a phone. */
.camera-grid { list-style: none; margin: 0; padding: 0; display: grid; gap: 1rem;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr)); }
.camera-grid > li { min-width: 0; }
/* ⭐ A tile's pictures (2026-10-02): "Now" and "Last motion" side by side where both
   exist, one where only one does. Each shot is as wide as half a tile or more, so a
   tile with two is no TALLER than one with a single frame was - the Cameras slide's
   footprint does not grow with the second image. `min(100%, 10rem)` lets a phone stack
   them. */
.camera-shots { display: grid; gap: 0.5rem;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 10rem), 1fr)); }
.camera-shot { margin: 0; min-width: 0; }
.camera-shot-label { margin: 0; font-weight: 600; }
.camera-stamp { margin-left: auto; font-variant-numeric: tabular-nums;
  font-weight: 600; }

/* --- the Cameras slide, laid out for a wall (kiosk, 2026-10-03) -----------------
   ⭐ **Pure black and white with alpha, written as `rgb()`, not tokens** (and allow-listed
   in `test_guards_color` for that reason). A picture is not themed and text over one needs
   a scrim that is dark in BOTH themes; a color token here would have to be a light/dark
   pair that means the same in both, which is what a literal black already is.
   ⭐ One large tile per camera, the FULL still, in a grid sized to the count
   (`--cols`, from `_wall_columns`). Every size is in `vmin`: the pane's viewport IS its
   box (the rotation frames it), so a caption reads the same on a 3840x2160 panel at scale
   1 as on a tablet. ⚠️ `contain`, not `cover`: a camera frame cropped to fill would cut
   the edge somebody is looking at, and the black behind it is the letterbox.
   ⛔ On a static display (nothing can touch it) the wall is PINNED to the viewport, the
   satellite slide's rule: a box measured against what an ancestor put above it is how that
   slide lost its bottom tenth. */
.camera-wall { display: grid; gap: 0.6vmin; margin: 0; padding: 0; border: 0;
  border-radius: 0; background: rgb(0 0 0); min-height: 70vh;
  grid-template-columns: repeat(var(--cols, 1), minmax(0, 1fr)); grid-auto-rows: 1fr; }
/* ⛔ A data attribute and four rules, not an inline `style`: the template ratchet counts
   inline styles and only lets them go down. Four columns is sixteen cameras. */
.camera-wall[data-cols="1"] { --cols: 1; }
.camera-wall[data-cols="2"] { --cols: 2; }
.camera-wall[data-cols="3"] { --cols: 3; }
.camera-wall[data-cols="4"] { --cols: 4; }
body.is-static .camera-wall { position: fixed; inset: 0; min-height: 0; }
/* ⭐ The tile is a CONTAINER, and the picture is the largest 16:9 box that fits it
   (`cqw`/`cqh`), centered. The caption and the motion inset hang on the PICTURE, not the
   cell: in a 3x2 grid the cell is nearly 4:3, and an inset on the cell's corner would float
   in the letterbox beside the frame. */
.camera-tile { margin: 0; min-width: 0; min-height: 0; overflow: hidden;
  background: rgb(0 0 0); container-type: size; display: grid;
  place-items: center; }
.camera-tile-pic { position: relative; aspect-ratio: 16 / 9;
  width: min(100cqw, 100cqh * 16 / 9); }
.camera-tile-main { display: block; width: 100%; height: 100%; object-fit: contain; }
.camera-tile figcaption { position: absolute; left: 0; right: 0; bottom: 0;
  display: flex; flex-wrap: wrap; align-items: baseline; gap: 0 1.6vmin;
  padding: 3vmin 1.8vmin 1.2vmin; color: rgb(255 255 255);
  background: linear-gradient(to top, rgb(0 0 0 / .78), rgb(0 0 0 / 0)); }
.camera-tile-name { font-size: 3vmin; font-weight: 600; }
.camera-tile-age { font-size: 2.2vmin; font-variant-numeric: tabular-nums; }
.camera-tile-source { font-size: 1.6vmin; opacity: .75; }
/* ⭐ The last motion, a small inset in the corner with its own age. */
.camera-tile-motion { position: absolute; top: 1.2vmin; right: 1.2vmin; width: 30%;
  background: rgb(0 0 0); border: 0.3vmin solid rgb(255 255 255 / .85);
  color: rgb(255 255 255); }
.camera-tile-motion img { display: block; width: 100%; aspect-ratio: 16 / 9;
  object-fit: cover; }
.camera-tile-motion span { display: block; padding: 0.4vmin 0.8vmin;
  font-size: 1.6vmin; background: rgb(0 0 0 / .78); white-space: nowrap; }
.chart-line { fill: none; stroke: var(--fgColor-accent); stroke-width: 2;
  stroke-linecap: round; stroke-linejoin: round;
  vector-effect: non-scaling-stroke; }
/* The plot floor, so a line hugging the bottom is distinguishable from a line
   that ran off the chart. */
.chart-frame { fill: none; stroke: var(--borderColor-default); stroke-width: 1;
  vector-effect: non-scaling-stroke; }
.chart-span { display: flex; justify-content: space-between; gap: 0.75rem;
  margin-top: 0.3rem; }
/* ⭐ The meeting page's day strip (`meeting.html`, 2026-09-29): scheduled
   meetings OUTLINED, detected ones FILLED, on one 7am-7pm line, so an early
   end or an unscheduled call is visible at a glance. */
.meeting-strip { display: block; width: 100%; height: 1.4rem; margin: 0.3rem 0; }
.meeting-scheduled { fill: none; stroke: var(--fgColor-muted); stroke-width: 1.5;
  stroke-dasharray: 4 3; vector-effect: non-scaling-stroke; }
.meeting-detected { fill: var(--fgColor-accent); stroke: none; }
/* ⭐ The SIGNAL and GATE lanes under a day's strip (`meeting.html`, 2026-10-05): what each signal did, then each candidate rule, on the
   same 7am-7pm line, so a rule can be chosen by eye. Three groups, no new color: the signals quiet, today's rule in the accent, the
   candidates plain. The scheduled blocks are outlined through all of them. Height follows the width (the viewBox keeps its ratio). */
/* ⭐ One grid row per lane: its name, then its strip. The label column is narrow enough for a phone; a strip
   is a fixed 0.6rem tall at any width (`preserveAspectRatio="none"`), so the bars never shrink to hairlines. */
.meeting-lane-grid { display: grid; grid-template-columns: minmax(6rem, 11rem) minmax(0, 1fr); column-gap: 0.5rem;
  row-gap: 0.15rem; align-items: center; margin: 0.3rem 0; }
.meeting-lane-label { font-size: 0.75rem; line-height: 1.1; white-space: nowrap; overflow: hidden;
  text-overflow: ellipsis; }
.meeting-lane { display: block; width: 100%; height: 0.6rem; }
.meeting-lane-heading { grid-column: 1 / -1; margin-top: 0.5rem; font-weight: 600; }
/* Equal cells, one per `MEETING_AXIS_STEP` from 7am: each label sits at its own hour, whatever the count. */
.meeting-lane-axis { display: flex; font-size: 0.7rem; }
.meeting-lane-axis span { flex: 1; }
@media (max-width: 600px) { .meeting-lane-grid { grid-template-columns: 6.5rem minmax(0, 1fr); } }
.meeting-lane-signal { fill: var(--fgColor-subtle); stroke: none; }
.meeting-lane-rule { fill: var(--fgColor-accent); stroke: none; }
.meeting-lane-candidate { fill: var(--fgColor-default); stroke: none; }
.meeting-lane-lit { fill: var(--fgColor-strong); stroke: none; }
.meeting-lane-key { margin: 0.3rem 0 0.6rem; padding-left: 1.2rem; }
/* ⭐ A plug's hourly PEAK, drawn under its average (`power.html`): muted and
   dashed, because it is the less exact of the two lines - the max of pushed
   readings, within ~10% and 15 s - and the average is what the eye should
   follow. The legend word takes the same color, so the key needs no swatch. */
.chart-line-peak { stroke: var(--fgColor-muted); stroke-width: 1.5;
  stroke-dasharray: 4 3; }
.chart-key-peak { color: var(--fgColor-muted); text-decoration: underline dashed; }

/* The color-temperature slider. ⚠️ Its track is a warm-to-cool ramp so the
   control shows what it does before it is moved - and the gradient runs LEFT to
   right in the reading direction even though the input is `dir="rtl"`, because
   the flip is what puts warm on the left. Getting only one of the two reversed
   would label the warm end as daylight.

   ⛔ The two stops are `--fgColor-attention` (a warm amber) and
   `--fgColor-accent`, both existing tokens - so both themes are covered by the
   token block and `test_guards_color` sees no new literal. */
/* ⛔⛔ **`input[type="range"].ct-slider`, not `.ct-slider` - and I got this
   wrong first, twice, in the same commit.** An element-plus-attribute selector
   is specificity 0-1-1 and a bare class is 0-1-0, so the field-skin resets
   above (`input[type="range"] { background: none }`,
   `input[type="color"] { width: auto }`) BEAT a class rule trying to restyle
   the same properties. The slider kept the default track and the color swatch
   collapsed to a sliver - both visible only in a render, neither caught by a
   test.

   ★ The file already had the answer twelve hundred lines up:
   `input[type="range"].brightness` is written exactly this way, for exactly
   this reason. */
input[type="range"].ct-slider { flex: 1; max-width: 9rem;
  /* ★ A warm amber rather than the app accent, so the control shows what it
     does before it is moved. ⚠️ `accent-color` only - this file's own note
     against hand-styling `::-webkit-slider-runnable-track` applies: styling the
     track means styling the thumb too, trading a cosmetic bug for a
     cross-browser one. A gradient here was the first attempt and it is not
     worth that. */
  accent-color: var(--fgColor-attention); }
.ct-value { min-width: 4.2rem; text-align: right; color: var(--fgColor-muted); }
.color-label { color: var(--fgColor-muted); font-size: 0.85rem; }
/* ⚠️ A real size, because a color input collapses to its intrinsic swatch -
   a tap target well under the 44px minimum the accessibility guard holds the
   rest of this app to. 2.75rem is 44px. */
input[type="color"].color-pick { width: 3rem; height: 2.75rem;
  cursor: pointer; }

    /* ---- the chore board ---------------------------------------------------

       ⭐⭐ **One markup tree, two layouts, and the mobile shape is the DEFAULT.**
       Written mobile-first with a `min-width` query for the columns, because the
       server cannot know the viewport and a second markup tree would put every
       row in the DOM twice - which breaks `test_shell`'s exactly-one
       `aria-current` for the same reason `NavGroup` refuses to place a link in
       two groups.

       ⚠️ Five columns do not fit a phone at any legible width. Stacked state
       groups are the owner's call over a horizontally-scrolling board: a board
       you have to scroll sideways hides the lane you are not looking at, which
       is the same objection that put the two Hue families in radio columns
       rather than a dropdown. */
    .chore-board { display: flex; flex-direction: column; gap: 1rem; }
    .chore-column {
      background: var(--bgColor-layer1); border: 1px solid var(--borderColor-muted);
      border-radius: 6px; padding: .6rem .7rem;
    }
    .chore-column-head {
      font-size: .9rem; margin: 0 0 .5rem; display: flex; align-items: center;
      gap: .4rem; color: var(--fgColor-muted); text-transform: uppercase;
      letter-spacing: .04em;
    }
    .chore-empty { margin: .2rem 0; }

    /* ⭐ The archive link sits in the `Completed` head, pushed to the far end
       so it reads as a way out of the lane rather than part of its title. */
    .chore-archive-link {
      margin-left: auto; font-size: .78rem; text-transform: none;
      letter-spacing: normal;
    }

    /* ⭐⭐ **One row per item in the banner lanes, and it is what the width
       BUYS.** `Unstarted` and `Completed` span all three tracks, so a card there
       has room to put its title, badges and meta on one line - which turns a
       lane read as a list into one line per chore instead of three.

       ⚠️ `flex-basis: auto` undoes the `100%` that forces the meta run onto
       its own line in a narrow lane; `margin-left: auto` then pushes it to the
       right edge. ★ `flex-wrap` is still on, so a long title on a phone wraps
       rather than overflowing - the rule improves a wide lane and cannot break
       a narrow one. */
    .chore-column--banner .chore-meta {
      flex-basis: auto; margin-left: auto; text-align: right;
    }

    /* One line per archived chore, same reading as the banner lanes. */
    .chore-archive-row {
      display: flex; flex-wrap: wrap; align-items: baseline; gap: .4rem;
    }
    /* ⛔⛔ **Needed for the same reason the banner rule is, and easy to
       miss.** `.chore-meta` is `flex-basis: 100%`, and the banner override is
       scoped to `.chore-column--banner` - a class this page has no ancestor
       for. Without this the archive would wrap to two lines per chore while
       the lane it was split off shows one. */
    .chore-archive-row .chore-meta { flex-basis: auto; margin-left: auto; }
    /* ★ The date sits after the meta run: the FIRST auto margin absorbs the
       free space, so both land at the right edge in that order. */
    .chore-archive-when { white-space: nowrap; }

    .chore {
      background: var(--bgColor-default); border: 1px solid var(--borderColor-muted);
      border-radius: 6px; margin-bottom: .4rem;
    }
    .chore:last-child { margin-bottom: 0; }
    .chore-summary {
      display: flex; flex-wrap: wrap; align-items: baseline; gap: .4rem;
      padding: .5rem .6rem; cursor: pointer;
      /* ★ 44px unconditionally - the app's own decision, not a pointer query:
         an iPad with a trackpad reports `pointer: fine`. */
      min-height: 44px;
    }
    .chore-title { font-weight: 600; }
    .chore-meta { flex-basis: 100%; }

    /* ⚠️ The pip carries the number as TEXT, not only as color. A
       color-only priority is unreadable to anyone who cannot see the hue, and
       this is the one field the board is sorted by. */
    .chore-priority {
      font-size: .7rem; font-weight: 700; padding: .1rem .35rem;
      border-radius: 4px; background: var(--bgColor-field);
      color: var(--fgColor-muted); white-space: nowrap;
    }
    .chore-priority[data-pri="1"] {
      background: var(--bgColor-danger-muted); color: var(--fgColor-danger);
    }
    .chore-priority[data-pri="2"] {
      background: var(--bgColor-attention-muted); color: var(--fgColor-attention);
    }

    .chore-body {
      border-top: 1px solid var(--borderColor-muted); padding: .6rem;
      display: flex; flex-direction: column; gap: .5rem;
    }
    .chore-locked { margin: 0; }
    .chore-form { display: flex; flex-direction: column; gap: .5rem; padding-top: .5rem; }
    .chore-form-row { display: flex; flex-wrap: wrap; gap: .6rem; }
    .chore-form-row .field-pair { flex: 1 1 8rem; }
    .chore-new-toggle { font-weight: 600; }
    .chore-actions { align-items: center; gap: .4rem; flex-wrap: wrap; }
    .chore-notes h3 { font-size: .85rem; margin: .3rem 0 .2rem; }
    .chore-note-list { margin: 0 0 .4rem; }
    .chore-note-list li { display: block; padding: .25rem 0; }

    /* ⭐ The columns arrive only when there is room for them. 60rem, not a
       device name: it is the width at which five lanes are still legible. */
    @media (min-width: 60rem) {
      /* ⭐⭐⭐ **Grid rather than a row of five, because the lanes are not
         peers.** The owner's layout: `Unstarted` full width on top, the three
         pipeline lanes side by side, `Completed` full width underneath. Three
         tracks, and the banner lanes span all of them.

         ★ **`STATES` order already IS this order**, so the grid needs no
         reordering and the DOM still reads top-to-bottom as it renders - which
         is what keeps one markup tree, one tab order, and the drag handlers
         (delegated on `.chore-board`, matched with `.closest`) working
         untouched.

         ⚠️ `minmax(0, 1fr)` rather than `1fr`: a long unbroken chore title
         would otherwise set a track's minimum to its own width and push the
         other two lanes off the grid. */
      .chore-board {
        display: grid; align-items: start;
        grid-template-columns: repeat(3, minmax(0, 1fr));
      }
      .chore-column--banner { grid-column: 1 / -1; }
    }

    .chore-template-list li {
      display: flex; flex-wrap: wrap; align-items: baseline; gap: .4rem;
      padding: .4rem 0; border-bottom: 1px solid var(--borderColor-muted);
    }
    .chore-template-list li:last-child { border-bottom: 0; }
    .chore-template-actions { margin-left: auto; gap: .3rem; }

    /* ---- the category tree ------------------------------------------------ */
    .chore-tree li { display: flex; flex-wrap: wrap; align-items: baseline;
      gap: .4rem; padding: .3rem 0; }
    /* ★ Indent by depth with an attribute selector rather than a computed
       inline style, so the ratchet stays at zero and the depth stays legible
       in the markup. */
    .chore-tree-row[data-depth="2"] { padding-left: 1.2rem; }
    .chore-tree-row[data-depth="3"] { padding-left: 2.4rem; }
    .chore-tree-label { font-weight: 600; }
    /* ★ The ancestor trail reads BEFORE the label and quieter than it,
       so the row scans as "under Maintenance → Cleaning: Recurring".
       ⛔ It replaced a full-path echo rendered at the end of the row,
       which stated a root’s own name twice and a leaf’s parent twice.
       The full path still exists on `data-path` for the confirm dialog. */
    .chore-tree-trail { font-weight: 400; }
    /* ★ Pushed right, where the removed path used to sit. It exists to
       explain an ABSENT Delete button - without it the missing button
       reads as a rendering bug rather than as a rule. */
    .chore-tree-count { margin-left: auto; }
    .chore-tree-delete { margin-left: .4rem; }

    /* ★ The deletable one looks different, because it is the only row on
       this page that offers a destructive button. `chore-fixed` and
       `chore-inuse` carry meaning only and take `.badge`'s neutral. */
    .badge.chore-unused { background: var(--bgColor-attention-muted);
      color: var(--fgColor-attention); }

    /* ---- the page tree ------------------------------------------------------
       ⚠️ `list-style` and `padding-inline-start` both stated: a `<ul>` carries
       a browser default indent, and the nested list needs a deliberate one
       rather than the browser's 40px. */
    .page-tree {
      list-style: none; padding-inline-start: 0; margin: .3rem 0 0;
      display: flex; flex-direction: column; gap: .15rem;
    }
    /* ⭐ The nested level is the only place an indent means anything here, so
       it is the only place one is applied. */
    .page-tree .page-tree { padding-inline-start: 1.1rem; }
    .page-tree-item { display: flex; flex-wrap: wrap; align-items: baseline; gap: .5rem; }
    /* ⛔⛔ **Setting `display` on a `<summary>` deletes the UA's disclosure
       triangle, and nothing reports it** - the row renders as text with no
       sign that it opens. Same trap `.chore-template-summary` documents, and
       the guard that catches it exists because it shipped once.
       ★ So the chevron is explicit, and as a flex item it sits on the baseline
       with the label rather than floating above it. */
    .page-tree-item summary { display: flex; flex-wrap: wrap; align-items: baseline; gap: .5rem; cursor: pointer; }
    .page-tree-item summary::before {
      content: "\25b8"; color: var(--fgColor-muted); font-size: .8em;
    }
    .page-tree-item[open] > summary::before { content: "\25be"; }
    /* ⛔ A row with no note carries no disclosure triangle, so it needs the
       same left edge as one that does or the list looks ragged. */
    .page-tree-bare { padding-left: 1.05rem; }
    .page-tree-note {
      margin: .3rem 0 .5rem 1.05rem; max-width: 62ch;
      color: var(--fgColor-muted);
    }

    /* ---- the city's collection calendar ------------------------------------
       ⭐ Three rules, because three of these classes change how something
       LOOKS and the badge variants do not - `chore-kind` and `chore-paused`
       take their whole appearance from `.badge`, so they are declared as
       rule-free hooks in `test_tokens_and_gzip.py` rather than given an empty
       block here. */
    /* ⚠️ `list-style` and `padding-inline-start` both stated: a `<ul>` carries
       a browser default indent that nothing else in this app resets, and the
       first version set only `list-style` and left a phantom 40px gutter. */
    .chore-municipal {
      list-style: none; padding-inline-start: 0; margin: .4rem 0 0;
      display: flex; flex-direction: column; gap: .35rem;
    }
    .chore-municipal li {
      display: flex; flex-wrap: wrap; align-items: baseline; gap: .4rem;
    }
    /* ⛔ Attention rather than the muted default: a holiday-slid collection is
       the one week the habit is wrong, so it has to survive a skim. */
    .chore-slid {
      background: var(--bgColor-attention-muted);
      color: var(--fgColor-attention);
      border: 1px solid var(--borderColor-attention-muted);
    }
    /* ⚠️ An unpublished recycling calendar means the chore stops appearing.
       That is deliberate and it is also easy to miss, so it reads as a notice
       rather than as more gray meta text. */
    .chore-unpublished {
      border-left: 3px solid var(--borderColor-attention);
      padding-left: .6rem;
      color: var(--fgColor-attention);
    }

    /* ---- schedules in English --------------------------------------------- */
    /* ★ Beside the expression, never instead of it: the cron is the stored
       value and stays visible, and the description is how a person checks it
       says what they meant. */
    .cron-english { margin-left: .4rem; font-style: italic; }
    /* ⭐ On the board the English is the schedule (the raw cron lives in the edit form): its own line under the name. */
    .chore-template-summary .cron-english { flex-basis: 100%; margin-left: 0; }
    /* ⭐ The zone reads as a fact about the expression, not commentary on it -
       so it sits tight against the cron in the same monospace weight rather
       than italic like the English gloss beside it. */
    .cron-zone {
      margin-left: .4rem; font-family: var(--mono, monospace);
      font-size: .82em; opacity: .75;
    }
    /* ⚠️ `margin` and `padding` both stated, because a `<fieldset>` carries a
       browser default `margin-inline: 2px` and a three-value padding that
       nothing else in this app resets - `test_guards_ua_defaults` caught the
       first version, which set padding and left the margin to luck. */
    .cron-builder {
      border: 1px solid var(--borderColor-muted); border-radius: 6px;
      padding: .6rem .7rem; margin: 0;
    }
    .cron-builder legend {
      font-size: .85rem; color: var(--fgColor-muted); padding: 0 .3rem;
    }

    /* ---- dragging a card between columns ---------------------------------- */
    /* ★ `grab` on the card, not `pointer`: the summary is also a disclosure
       toggle, and two affordances on one element need the cursor to say which
       gesture the surface as a whole offers.
       ⚠️ `is-dragging` is also used by `.floor-plan-viewport` for its pan
       gesture. Shared deliberately - it means the same thing in both, and both
       selectors are element-scoped so neither can reach the other. That is the
       opposite of the `.off` problem, where one word carried five meanings. */
    /* ⛔⛔ **`touch-action` is the whole reason this gesture works on a
       touchscreen, and it is a compromise stated out loud.** `pan-y` keeps
       VERTICAL panning with the browser and hands everything else to the page,
       so a scroll that begins on a card still scrolls while a sideways drag
       becomes a move. The script requires horizontal dominance for touch to
       match it.
       ★ It fits the layout rather than fighting it: at `min-width: 60rem` the
       board is three side-by-side grid columns, so moving a card between lanes
       IS a horizontal gesture. Below that the lanes are stacked and scrolling
       matters more than dragging - which is the trade this line makes.
       ⚠️ `none` here would make dragging work in any direction and cost the
       page its scroll wherever a finger happens to land on a card, which on
       the stacked layout is most of the screen. */
    .chore > .chore-summary { cursor: grab; touch-action: pan-y; }
    .chore.is-dragging { opacity: .5; }
    /* ⚠️ An inset ring rather than a border: a border would change the
       column's box and shift every card down a pixel as you drag over it. */
    .chore-column.is-drop-target {
      box-shadow: inset 0 0 0 2px var(--bgColor-accent-emphasis);
    }

    /* The card that follows the pointer. ⛔ `pointer-events: none` is
       load-bearing rather than tidy: the script hit-tests with
       `elementFromPoint` to find the column under the finger, and without this
       the answer is always the ghost.
       ⚠️ Centered on the pointer with a second `translate`, so the composition
       order matters - the custom properties place it, the percentages offset
       it, and swapping them would offset by the pointer position instead. */
    .chore-ghost {
      position: fixed; left: 0; top: 0; z-index: 40; pointer-events: none;
      transform: translate(var(--ghost-x, 0px), var(--ghost-y, 0px))
                 translate(-50%, -50%);
      max-width: 14rem; overflow: hidden; text-overflow: ellipsis;
      white-space: nowrap;
      padding: .35rem .6rem; border-radius: 6px;
      background: var(--bgColor-layer1);
      border: 1px solid var(--borderColor-default);
      box-shadow: 0 6px 20px rgba(0,0,0,.45);
      font-size: .9rem;
    }

    /* ---- a title, linked to IMDb ----------------------------------------- */
    /* ★ Inherits the row's color rather than taking the link accent. Forty
       accent-colored titles down a page would read as a list of links with
       some text attached, when the title IS the row - the underline on hover
       is enough to say it goes somewhere. */
    .imdb-link { color: inherit; text-decoration: none; }
    .imdb-link:hover, .imdb-link:focus-visible { text-decoration: underline; }
    /* ⛔ **A search link must not look like a title link.** It may land on a
       disambiguation page or the wrong year, and the first time it opens the
       wrong film somebody stops trusting all of them - so it carries a mark
       that says "this is a search", and its `title` says so in words. */
    .imdb-search::after {
      content: "\2197"; font-size: .75em; margin-left: .15rem;
      color: var(--fgColor-muted);
    }

    /* ---- a media row: a title, its detail, and what you can do with it --- */
    /* ⭐⭐ **The actions sit BESIDE the title, not under it.** Every list on the
       TV pages spent a whole line on its buttons - the catalog two rows per
       title, Plex and Streaming three - so forty titles was eighty to a hundred
       and twenty rows of scrolling.

       ⛔ **And it fixes a stated legibility defect, not just density.**
       `streaming.html` records it: a button row on its own line "sits directly
       under the previous row's meta line. Scanning down, that `[Play]` reads as
       belonging to 'King of the Hill'. It does not." Beside its own title, a
       button cannot be read as belonging to the row above.

       ★ Mobile-first: stacked by default, and the grid only above 45rem. A
       narrow screen has no room to put three buttons beside a title, and
       forcing it would squeeze the title to nothing. */
    .media-list > li > .row { margin-top: .3rem; }
    @media (min-width: 45rem) {
      .media-list > li {
        display: grid; grid-template-columns: minmax(0, 1fr) auto;
        align-items: center; column-gap: .75rem;
      }
      /* ⚠️ Everything is column ONE unless it says otherwise. Grid
         auto-placement would run children across the columns in source order,
         which puts a meta line where the buttons belong. */
      .media-list > li > * { grid-column: 1; }
      .media-list > li > .row-head { margin-bottom: .1rem; }
      .media-list > li > .meta { margin-bottom: 0; }
      /* The actions span every row of the item, so they center against a
         title-plus-detail pair as readily as against a title alone. */
      .media-list > li > .row {
        grid-column: 2; grid-row: 1 / -1; margin-top: 0;
      }
    }

    /* ---- a room or a zone, with its members ------------------------------- */
    /* ⭐ The area's own controls are always visible and the member list is
       what collapses. That is why they are siblings rather than a `<summary>`
       and its body: a `<button>` inside a summary toggles the disclosure as
       well as firing itself, so an area switch in there would expand the room
       every time somebody turned it off. */
    .area-list > .area { padding-bottom: .4rem; }
    /* ★ The head is a `.row-head` as well, so it inherits the flex layout
       every other row in the app uses. This adds only the emphasis that says
       an area is a heading for the lights beneath it. */
    .area-head .row-name { font-weight: 600; }
    /* ⚠️ Indented and quieter than the area above it, so an expanded card
       reads as a room CONTAINING lights rather than as a flat list that
       happens to be adjacent. */
    .area-members {
      margin-top: .4rem; padding-left: .8rem;
      border-left: 2px solid var(--borderColor-muted);
    }
    /* ⛔⛔ **`display: flex` on a summary deletes the disclosure triangle**
       - the marker comes from the UA `display: list-item` and flex replaces
       it. Second time this bit: the chore Scheduler had the identical bug,
       and both times a render was the only thing that could see it. There is
       now a guard, `test_guards_css_summary_marker`. */
    .area-members > summary::before {
      content: "\25b8"; margin-right: .35rem;
    }
    .area-members[open] > summary::before { content: "\25be"; }
    .area-members > summary {
      cursor: pointer; color: var(--fgColor-muted); font-size: .9rem;
      /* ★ 44px unconditionally, the app's own decision rather than a pointer
         query - this is the control that opens the room. */
      min-height: 44px; display: flex; align-items: center;
    }
    .area-member-list > li { padding: .3rem 0; }
    /* ★ The color pair sits under the row it belongs to, indented so
       it reads as detail rather than as another light. */
    .area-color-row, .member-color-row {
      flex-wrap: wrap; padding-left: .2rem;
    }
    /* ⭐ The status on a light’s title row rather than a line of its
       own. `flex-shrink: 0` so a long name pushes the slider, never
       this - a wrapped status reads as a second row, which is the very
       thing it was moved to avoid. */
    .conn-inline { flex-shrink: 0; white-space: nowrap; }
    /* The assignment page: an area and the lights it holds. */
    .assign-list > li { padding-bottom: .5rem; }
    .assign-members {
      margin-top: .3rem; padding-left: .8rem;
      border-left: 2px solid var(--borderColor-muted);
    }
    /* ★ Pushed to the end of the member row, away from the switch and the
       slider: it is the one control on that row that is not about the
       light’s state, and a mis-tap costs a confirm rather than a bulb. */
    .area-remove { margin-left: auto; }
    /* The add-a-light picker sits under the member list it extends. */
    .area-add { margin-top: .4rem; gap: .4rem; }

    /* ---- a recurring definition, expanded ---------------------------------- */
    /* ⭐ The same shape the board uses for a chore: the summary is the row you
       already read, and expanding it reveals the fields. Deliberately not a
       dialog - editing a schedule is a considered act, and a person wants the
       other definitions still visible while they do it. */
    /* ★ An open definition gets breathing room, so a form does not read as
       belonging to the row below it. */
    .chore-template[open] { padding-bottom: .3rem; }
    .chore-template-summary {
      display: flex; flex-wrap: wrap; align-items: baseline; gap: .4rem;
      cursor: pointer;
    }
    /* ⛔⛔ **`display: flex` on a `<summary>` deletes the disclosure
       triangle**, because the marker comes from the UA `display: list-item`
       and flex replaces it. A render was the only thing that could catch it:
       the markup is correct, every test passed, and the rows simply looked
       like text with no hint that they opened.
       ★ So the chevron is explicit. As a flex item it also sits on the
       baseline with the title instead of floating above it. */
    .chore-template-summary::before {
      content: "\25b8"; color: var(--fgColor-muted); font-size: .8em;
    }
    .chore-template[open] > .chore-template-summary::before {
      content: "\25be";
    }
    .chore-template-edit {
      margin-top: .6rem; padding-top: .6rem;
      border-top: 1px solid var(--borderColor-muted);
    }
    /* ★ A per-row status line, so a save says something next to the button
       that was pressed rather than only in the global announcer. */
    .chore-template-said { min-height: 1.2em; }
    /* ⚠️ Was an id. It became a class the moment a second builder existed -
       duplicate ids render fine and behave wrongly, which is the worst
       combination available. */
    .cron-preview { min-height: 1.2em; }

    /* ---- board filters ----------------------------------------------------- */
    .chore-filters { margin-bottom: 1rem; }
    .chore-filter-said { margin-left: .4rem; }
    /* ⚠️ Capped INSIDE `.chore-filters` only. These reuse `.chore-form`,
       where a full-width field is right - a description wants the room. A
       filter does not: a render showed "Points at least" as a 300px box for
       three characters, and seven of those read as a form to fill in rather
       than a row of switches. */
    .chore-filters .chore-form-row > .field-pair { max-width: 14rem; }
    /* --- the kiosk rotation ---------------------------------------------------
       ⛔⛔ **Stacked and all laid out, switched by OPACITY - never
       `display: none`.** Two reasons, and the second is not obvious:

       1. A `display: none` iframe is not rendered, so switching to it shows an
          unpainted frame for a beat. Opacity keeps every slide painted.
       2. ⚠️ `loading="lazy"` images - which the satellite slide uses - only fetch
          when they intersect the viewport. A hidden frame never intersects, so the
          picture would begin loading at the moment it became visible, which is
          exactly when it has to already be there.

       ★ The cost is honest: eleven full-size frames stay composited. Not measured
       yet; if it hurts, the fix is fewer slides rather than `display: none`, which
       would reintroduce both problems above. */
    .kiosk-rotation {
      position: relative;
      width: 100%;
      /* ⚠️ `100vh`, not `100%`: the slides are absolutely positioned, so this has
         no content to take its height from and would collapse to nothing. */
      height: 100vh;
    }

    /* ⛔⛔ **A viewport tall is not the same as filling the viewport.** The rule
       above is a viewport tall starting BELOW `main`'s padding, so on the
       television every slide lost that strip off its bottom edge and the page
       scrolled by exactly that much - a scrollbar the panel drew down its
       right edge on all ten slides. ★ Measured off a photograph: 77px, on a
       satellite picture that should have been 2160 and was 2083.
       ⚠️ The same mistake as `body.is-static .wx-goes` in #1003, one level up,
       and the same fix: a box that must fill the screen is measured against
       the screen, never against what an ancestor put above it. */
    .kiosk-rotation.is-pinned { position: fixed; inset: 0;
                                width: auto; height: auto; }

    /* ⭐⭐ A slide is a ROW of panes now. One pane fills it; two share it
       evenly, which is the whole of "use the horizontal space". */
    .kiosk-slide {
      position: absolute;
      inset: 0;
      display: flex;
      /* ⚠️ A gap, not a border: two documents side by side need a seam a
         reader can see, and a border would be inside one of them. */
      gap: var(--space-3, 0.75rem);
      opacity: 0;
      /* ⭐ A cross-fade rather than a cut, which hides the one-frame repaint when
         a slide that was reloaded off-screen comes back. */
      transition: opacity var(--motion-fast) ease-in-out;
      /* ⛔ Nothing on this display can be clicked - there is no pointer - but a
         frame that can take focus can still scroll itself out of position if
         anything ever sends it a key. */
      pointer-events: none;
    }

    .kiosk-slide[data-showing] {
      opacity: 1;
    }

    /* ⭐ **Held while the TV is off HDMI 3** (2026-10-12): every pane is `about:blank` and the slides are hidden over black, so what is left on screen is nothing. */
    .kiosk-rotation.is-away { background: var(--bgColor-inset); }
    .kiosk-rotation.is-away .kiosk-slide { opacity: 0; }

    /* ⭐ `flex: 1` rather than a width, so one pane takes the row and two
       split it without the template knowing how many there are. */
    /* ⭐⭐ Back and forward on a rotation somebody can point at (`rotation.shell_controls`): two buttons at the left and right
       edges, over the slides, low-contrast until hovered or focused so a preview still reads as the wall. ⚠️ The slides and
       panes are `pointer-events: none` (nothing on a wall can be clicked), so these set it back for themselves alone. */
    .kiosk-nav {
      position: absolute;
      top: 50%;
      transform: translateY(-50%);
      z-index: 5;
      pointer-events: auto;
      width: 3rem;
      height: 6rem;
      padding: 0;
      border: 0;
      border-radius: 0.5rem;
      background: transparent;
      color: var(--fgColor-default);
      opacity: 0.25;
      cursor: pointer;
      transition: opacity var(--motion-fast) ease-in-out, background var(--motion-fast) ease-in-out;
    }
    .kiosk-nav svg { width: 2rem; height: 2rem; display: block; margin: 0 auto; }
    .kiosk-nav-prev { left: 0.5rem; }
    .kiosk-nav-next { right: 0.5rem; }
    /* The preview's pause, bottom center; its icon says what a press will do (pause while rotating, play while paused). */
    .kiosk-nav-pause { top: auto; bottom: 0.5rem; left: 50%; transform: translateX(-50%); height: 3rem; }
    .kiosk-nav-pause .kiosk-icon-play,
    .kiosk-nav-pause[aria-pressed="true"] .kiosk-icon-pause { display: none; }
    .kiosk-nav-pause[aria-pressed="true"] .kiosk-icon-play { display: block; }
    .kiosk-nav-pause[aria-pressed="true"] { opacity: 1; }
    .kiosk-nav:hover, .kiosk-nav:focus-visible { opacity: 1; background: var(--bgColor-layer1); }
    .kiosk-nav:focus-visible { outline: 2px solid var(--fgColor-accent, currentColor); outline-offset: 2px; }

    /* ⭐ The preview's night-posture override: three small buttons in the top corner, low-contrast until hovered or focused, like the arrows. */
    .kiosk-posture { position: absolute; top: 0.5rem; right: 0.5rem; z-index: 5; pointer-events: auto; display: flex; gap: 0.25rem;
      opacity: 0.25; transition: opacity var(--motion-fast) ease-in-out; }
    .kiosk-posture:hover, .kiosk-posture:focus-within { opacity: 1; }
    .kiosk-posture button { font: inherit; font-size: 0.8rem; padding: 0.25rem 0.6rem; border: 1px solid var(--borderColor-default);
      border-radius: 0.4rem; background: var(--bgColor-layer1); color: var(--fgColor-default); cursor: pointer; }
    .kiosk-posture button[aria-pressed="true"] { border-color: var(--fgColor-accent, currentColor); font-weight: 600; }
    .kiosk-posture button:focus-visible { outline: 2px solid var(--fgColor-accent, currentColor); outline-offset: 2px; }

    .kiosk-pane {
      flex: 1;
      min-width: 0;
      height: 100%;
      border: 0;
      /* ⛔ Nothing on this display can be clicked - there is no pointer - but
         a frame that can take focus can still scroll itself out of position if
         anything ever sends it a key. */
      pointer-events: none;
    }

    /* --- a display nothing can touch -------------------------------------
       ⛔ The page title, hidden rather than deleted. A rotating slide is a
       SECTION of a page, and the card heading under this already names it -
       so the h1 says the same thing twice on a screen where every line costs.
       ⚠️ `display: none` rather than a template edit in eleven files: the
       element stays in the DOM for anything that parses the page, and one
       rule cannot drift out of step with ten others. */
    body.is-static h1 { display: none; }

    /* --- a display nothing can touch has no links and no instructions ------------
       ⭐⭐ **One rule for every page** (2026-10-03). The living-room panel has no pointer, so
       an underlined, colored word on it is a promise nothing can keep, and a sentence that
       says "Press Not bought" or "PUT /..." is documentation for somebody who is not there.
       Two halves:
       1. **A link is plain text**: it keeps its words and loses the color and the underline.
          ⚠️ Keyed on `is-static`, not on `kiosk`: a touch kiosk (the wall tablet) CAN press a
          link, so there they stay links; this is the HDMI panel's rule, the same split the
          buttons already follow (`static()`).
       2. **`.kiosk-hide` is an element a template marked as an instruction or a link row**
          ("Press *Not bought*", the archive link line, `PUT /...` hints, "Categories ·
          Scheduler" rows). Marked in the template, not found here, because only the template
          knows what is a hint and what is the page's content. `test_kiosk_cleanup` renders
          the rotation's pages and fails on one that slipped through.
       3. **A CLOSED disclosure is hidden** (2026-10-04, the wall capture: the forecast's "▶ Detail" on
          every period, the forecaster's discussion, an alert's "What to do"). On a screen nothing can
          press, a closed `<details>` is only its label - a control that can never open. One rule here
          rather than a mark on each; an OPEN one is content and stays.
          ⛔ **Except a disclosure whose SUMMARY is the content: `.static-keep`.** Overturned the same day:
          *"every closed disclosure is that by construction"* was false. A chore card is a closed
          `<details>` whose summary IS the card (title, priority, owner), so the rule emptied the wall's
          Chores slide down to its column heads (#1678, found by the owner). The card is kept; its closed
          body stays closed, which is the static page's answer anyway. */
    body.is-static a { color: inherit; text-decoration: none; }
    body.is-static .kiosk-hide { display: none; }
    body.is-static details:not([open]):not(.static-keep) { display: none; }
    /* A kept card's disclosure marker is a control nothing on the wall can press, so it goes too; the card stays. */
    body.is-static details.static-keep > summary { list-style: none; cursor: default; }
    body.is-static details.static-keep > summary::-webkit-details-marker { display: none; }

    /* ⭐ No pointer means no scrollbar worth drawing, and it was visible down
       the right of every frame on the television.

       ⛔⛔ **On `body` alone this does nothing, and it went on doing nothing
       for two rounds of "the scrollbar is still there".** The viewport's
       scrollbar is controlled by the ROOT element, and `scrollbar-width` -
       unlike `overflow` - does NOT propagate up from `body`. So the rule was
       valid, supported (Chrome 153 on the panel), applied to the right
       element, and had no effect on the bar anybody could see.
       ★ `:has()` is what lets a rule on the root be conditioned on the class
       the server puts on the body. ⚠️ Both rules stay: the `body` one still
       governs any scroll container inside the page. */
    html:has(body.is-static) { scrollbar-width: none; }
    body.is-static { scrollbar-width: none; }
    body.is-static::-webkit-scrollbar { display: none; }

    /* --- a wall display is wider than a desk ------------------------------
       ⛔⛔ **`main` is capped at `--measure-wide: 900px`, and that cap was the
       whole problem.** It is right for a desk - a 900px measure keeps a line of
       text readable - and wrong for a 4K panel across a room, where it left
       roughly half the screen black with a single column down the middle.

       ★ The grids were never the issue. `.tiles` and `.row-grid` are both
       `auto-fit`, so they add columns as soon as there is room; the container
       was simply not giving them any. The owner's "lights are still one column"
       and "we're still wasting horizontal space" are one fix.

       ⚠️ Only on a static display. Widening this everywhere would stretch
       prose to an unreadable measure on a laptop, which is what the cap exists
       to prevent. */
    body.is-static main { max-width: none; }

    /* ⭐⭐ **Tighter rows, because the wall shows the whole list.** The
       hourly forecast is 24 rows on a display with no scrollbar and nobody to
       scroll it - at the interactive spacing about fifteen fit and the rest
       is below a fold that cannot be crossed.
       ⚠️ Padding and the rule between rows, not font size: shrinking the
       type is the wrong lever on the screen furthest from the reader. The
       border carries the separation at half the gap.
       ★ Measured against the real list: 24 rows at .6rem needed ~2,400px of
       a 2,160px pane; at .28rem they fit with room for the card head. */
    body.is-static .rows > li { padding: .28rem 0; }

    /* ⚠️ **And a floor, or the fix overshoots.** At ~1600 CSS px with a 14rem
       floor, `.tiles` would lay out seven columns - each narrower than the
       phone layout, on the screen furthest from the reader. Four is the number
       that keeps a tile's figure and its caption on the same line. */
    body.is-static .tiles { grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr)); }

    /* --- the satellite, scaled to the panel -------------------------------
       ⭐ The picture is the slide, so it takes the height and keeps its
       aspect. ⚠️ `object-fit: contain` rather than `cover`: a weather image
       cropped to fill would cut off exactly the edge somebody is looking for.

       ⛔⛔ **`height: 100vh` in normal flow was wrong, and the panel showed
       exactly how wrong.** The box WAS a viewport tall - but it began 230px
       down, under `main`'s padding and the card's, so the bottom tenth of the
       picture ran off the screen. And a square frame in a box only as wide as
       it is tall left the horizontal space this slide was supposed to reclaim.
       ★ Pinned to the viewport instead, so the picture's size stops depending
       on what any ancestor put above it. `only=image` has already cleared the
       page, so there is nothing for it to cover. */
    body.is-static .wx-goes { position: fixed; inset: 0; margin: 0;
                              border-radius: 0; }
    body.is-static .wx-goes img {
      width: 100%;
      height: 100%;
      object-fit: contain;
    }

/* --- the lights glance: one dot per light, read from across a room --------
   ⭐ Sized for DISTANCE, not for density. Every other list in this app is
   tuned for a person at a desk; this one is tuned for the wall panel, where
   the reader is eight feet away and is not going to lean in. */
.glance-rooms {
  display: grid;
  /* ⚠️ Wide tracks on purpose. A room's dots should sit on one or two rows,
     not seven - a column narrow enough to wrap eight bulbs into a tall
     stack turns a glance into reading. */
  grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
  gap: 1rem;
}
.glance-room-name {
  font-size: 1.05rem;
  margin: 0;
  border: 0;                        /* h2 elsewhere carries a rule; a card of
                                       dots does not need a second divider */
  padding: 0;
}
.glance-room-count {
  color: var(--fgColor-muted);
  font-size: .82rem;
  margin: .1rem 0 .7rem;
}
.glance-dots {
  display: flex;
  flex-wrap: wrap;
  gap: .85rem 1rem;
  list-style: none;
  margin: 0;
  padding: 0;
}
.glance-light {
  display: flex;
  flex-direction: column;
  align-items: center;
  /* ⚠️ A fixed track, so dots line up in columns whatever their labels say.
     Without it a long bulb name widens its own cell and the row of discs
     stops reading as a row. */
  width: 4.2rem;
  gap: .2rem;
}
.glance-dot {
  width: 2.6rem;
  height: 2.6rem;
}
/* ⛔ Off and unreachable differ in KIND from on - hollow against filled -
   and unreachable differs from off in COLOR as well as dash, because a fault
   should not rest on a reader resolving a 3px dash pattern from eight feet.
   ★ `currentColor` is what both ring states stroke with, so the ring color is
   set here and inherited by the SVG: theming stays in CSS while the fill of a
   lit disc stays data. */
/* ⭐ The quiet marker shown when a live display could not get live state and the cloud values are what is on screen. Fixed and
   out of flow, so it can never move a dot or resize a room. */
.glance-live-mark { position: fixed; right: .8rem; bottom: .5rem; margin: 0; font-size: .75rem; color: var(--fgColor-muted);
  opacity: .7; pointer-events: none; }
body.is-static .glance-live-mark { right: 1vmin; bottom: .6vmin; font-size: 1.3vmin; }

.glance-light.is-off .glance-dot { color: var(--borderColor-emphasis); }
.glance-light.is-unreachable .glance-dot { color: var(--fgColor-attention); }
/* ⭐ **A lit light's NAME reads at full contrast, an unlit one stays muted.**
   Not decoration: the names worth reading are the ones attached to something
   that is on, and muting the rest is a second, redundant channel saying the
   same thing the disc does - which is what you want on a wall, where one
   channel can be lost to glare or to angle. */
.glance-light.is-on .glance-name { color: var(--fgColor-default); }
.glance-name {
  font-size: .72rem;
  line-height: 1.15;
  text-align: center;
  color: var(--fgColor-muted);
  /* Two lines, then clipped. A third line would push the next row of dots
     out of alignment, which costs more than the rest of a bulb's name. */
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
/* ⛔⛔ **The wall panel is a DIFFERENT layout, not the same one scaled up a
   third.** The first version of this block said `3.4rem` and it was wrong in
   the way only a render shows: on the 4K panel the whole page occupied the top
   9% of the screen and the remaining 90% was black. Nothing in the markup was
   incorrect, every test passed, and the page was useless - the same shape as
   #1003, #1004 and #1005, where a stylesheet was present, correct, asserted,
   and the picture still ran off the panel.

   ★ So everything here is in `vmin`, which on a pane is the pane: slides are
   iframes (`kiosk_rotation.html`), so the pane document's viewport IS its box
   and viewport units mean what they say. `rem` cannot work at all - the root
   font size is the same 16px whether the box is a phone or a television. */

/* ⭐ `position: fixed; inset: 0` and nothing else. `height: 100%` needs an
   unbroken chain of sized ancestors, and a `100vh` box in normal flow is a
   viewport TALL starting wherever its ancestors put it - which cost three
   separate commits this week at two nesting levels. ⚠️ Safe to take the whole
   pane because `body.is-static h1` is already `display: none` and the intro
   paragraph is behind the template's own static() test: this grid is the only
   thing on a static render of the page.

   ⛔ **And this comment may not quote that test.** This stylesheet is a Jinja
   template - it is `` by base.html, not linked - so a tag
   written inside a CSS comment is parsed as a tag. Writing the real syntax
   here took the whole app down with `Unexpected end of template`, from a
   comment. */
body.is-static .glance-rooms {
  position: fixed;
  inset: 0;
  padding: 1.6vmin;
  /* ⚠️ `vw`, not `rem`. Three columns on the 4K panel, and still one column on
     a narrow pane - the room count picks the rows and the PANEL picks the
     size, rather than a number tuned to one screen.
     ⛔ Wrapped in `min(..., 100%)` because `auto-fit` drops empty tracks but
     never shrinks one below its own floor, so an unclamped floor overflows
     rather than collapsing - #593 shipped that on /ui/traffic. A bare `26vw`
     could not actually overflow, but `test_a_grid_floor_cannot_exceed_a_phone`
     cannot verify a viewport unit, and the clamped form is what it accepts:
     cheaper to write the provably-safe spelling than to teach the guard a
     second kind of safe.
     ⛔⛔ **22vw, and the number came off the WALL, not off a fixture.** At
     26vw this is three columns; the real house has ten rooms, which is four
     rows, which MEASURED 174px past the bottom of the panel - a tenth room
     cut off, which is the same lost-bottom-strip defect as #1004 arriving by a
     different route. Four columns puts ten rooms in three rows with slack left
     over. ⚠️ A six-room fixture fit at 26vw and looked finished, so the
     rendering that proved this had to use the real room count:
     `infra/measure_kiosk_fit.ps1` is how the overflow is read as a number
     rather than judged from a screenshot. */
  grid-template-columns: repeat(auto-fit, minmax(min(22vw, 100%), 1fr));
  /* ⛔⛔ **`minmax(min-content, 1fr)`, and the two obvious alternatives were
     both tried on the panel.** `space-between` fills the panel by pushing
     natural-height rows apart - and with six rooms in two rows it left a
     700-pixel BLACK BAND across the middle of the screen, which reads as a
     broken page rather than as spacing. Plain `1fr` goes the other way and
     clips the dots out of any room whose share is shorter than its content.
     ★ `minmax(min-content, 1fr)` is the one that cannot do either: every row
     is at least as tall as what is in it, and the slack goes into the cards
     instead of between them. */
  grid-auto-rows: minmax(min-content, 1fr);
  gap: 1.6vmin;
  /* ⚠️ The escape hatch, for a house with more rooms than this one. It scrolls
     inside the grid rather than letting the page grow past the panel, which is
     what loses the bottom strip. Hidden bar: nothing is going to scroll it. */
  overflow-y: auto;
  scrollbar-width: none;
}
body.is-static .glance-rooms::-webkit-scrollbar { display: none; }
/* ⭐ The slack that `1fr` puts INTO a card goes above and below its dots
   rather than under them, so a room with two bulbs reads as a deliberate
   panel instead of a label with a lot of nothing beneath it. */
body.is-static .glance-room { display: flex; flex-direction: column; }
body.is-static .glance-dots { flex: 1; align-content: center; }
body.is-static .glance-room-name { font-size: 2.6vmin; }
body.is-static .glance-room-count { font-size: 1.8vmin; margin-bottom: 1.4vmin; }
body.is-static .glance-dots { gap: 1.4vmin 1.8vmin; }
body.is-static .glance-dot { width: 8vmin; height: 8vmin; }
body.is-static .glance-light { width: 11vmin; gap: .6vmin; }
body.is-static .glance-name { font-size: 1.5vmin; }

/* --- the lights wall: EVERY room in a fixed cell (2026-10-03) ---------------------------------
   ⭐⭐ The route draws every room in `glance.fixed_order` (downstairs, upstairs, alphabetical) and
   picks the grid and the dot size with `glance.fit`, so a lit room is always in the same place and
   nothing overflows: the owner rejected hiding the unlit rooms (#1613) because the lit ones then
   moved. ⛔ **The sizes are in `vmin` and the arithmetic is in `glance.fit`'s constants**, which a
   test holds against these numbers. `data-cols`/`data-rows` pick the tracks and `data-dot` the dot
   (tenths of a vmin) as attributes and rules, not an inline `style` (the template ratchet). The
   grid keeps its fixed inset and its hidden scrollbar as the last resort. */
body.is-static .glance-rooms[data-cols] { overflow: hidden; gap: .8vmin; padding: .8vmin;
  grid-auto-rows: auto; }
body.is-static .glance-rooms[data-cols="1"] { grid-template-columns: repeat(1, minmax(0, 1fr)); }
body.is-static .glance-rooms[data-cols="2"] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
body.is-static .glance-rooms[data-cols="3"] { grid-template-columns: repeat(3, minmax(0, 1fr)); }
body.is-static .glance-rooms[data-cols="4"] { grid-template-columns: repeat(4, minmax(0, 1fr)); }
body.is-static .glance-rooms[data-cols="5"] { grid-template-columns: repeat(5, minmax(0, 1fr)); }
body.is-static .glance-rooms[data-cols="6"] { grid-template-columns: repeat(6, minmax(0, 1fr)); }
body.is-static .glance-rooms[data-rows="1"] { grid-template-rows: repeat(1, minmax(0, 1fr)); }
body.is-static .glance-rooms[data-rows="2"] { grid-template-rows: repeat(2, minmax(0, 1fr)); }
body.is-static .glance-rooms[data-rows="3"] { grid-template-rows: repeat(3, minmax(0, 1fr)); }
body.is-static .glance-rooms[data-rows="4"] { grid-template-rows: repeat(4, minmax(0, 1fr)); }
body.is-static .glance-rooms[data-rows="5"] { grid-template-rows: repeat(5, minmax(0, 1fr)); }
body.is-static .glance-rooms[data-rows="6"] { grid-template-rows: repeat(6, minmax(0, 1fr)); }
body.is-static .glance-rooms[data-cols] .glance-room { min-height: 0; overflow: hidden;
  padding: .9vmin 1.4vmin; }
/* ⛔⛔ **Every box has an explicit height, so no font can change one** (the constants in `glance.py`
   are these numbers; a test reads this file and holds them equal). The first version let the browser
   size the name, the count and the label from their text: it fit on Windows with 3 px to spare and
   overflowed on the appliance's Linux Chrome, whose `system-ui` face has taller lines. Text is clamped
   INSIDE its box (`overflow: hidden`, one line with an ellipsis for the name and the count, two lines
   for a label), so a long or tall face costs a truncation, never a cut-off row. */
body.is-static .glance-rooms[data-cols] .glance-room-name { font-size: 1.9vmin; line-height: 1.1;
  height: 2.1vmin; margin: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
body.is-static .glance-rooms[data-cols] .glance-room-count { font-size: 1.25vmin; line-height: 1.2;
  height: 1.5vmin; margin: .1vmin 0 .6vmin; overflow: hidden; white-space: nowrap; }
body.is-static .glance-rooms[data-cols] .glance-dots { flex: 0 1 auto; align-content: flex-start;
  gap: .6vmin; min-height: 0; }
body.is-static .glance-rooms[data-cols] .glance-light { gap: .3vmin; }
body.is-static .glance-rooms[data-cols] .glance-dot { flex: none; }
body.is-static .glance-rooms[data-cols] .glance-name { font-size: 1.25vmin; line-height: 1.1;
  height: 2.75vmin; flex: none; }
body.is-static .glance-rooms[data-dot="80"] .glance-dot { width: 8vmin; height: 8vmin; }
body.is-static .glance-rooms[data-dot="80"] .glance-light { width: 12.5vmin; height: 11.05vmin; }
body.is-static .glance-rooms[data-dot="70"] .glance-dot { width: 7vmin; height: 7vmin; }
body.is-static .glance-rooms[data-dot="70"] .glance-light { width: 10.9vmin; height: 10.05vmin; }
body.is-static .glance-rooms[data-dot="61"] .glance-dot { width: 6.1vmin; height: 6.1vmin; }
body.is-static .glance-rooms[data-dot="61"] .glance-light { width: 9.5vmin; height: 9.15vmin; }
body.is-static .glance-rooms[data-dot="52"] .glance-dot { width: 5.2vmin; height: 5.2vmin; }
body.is-static .glance-rooms[data-dot="52"] .glance-light { width: 8.1vmin; height: 8.25vmin; }
body.is-static .glance-rooms[data-dot="45"] .glance-dot { width: 4.5vmin; height: 4.5vmin; }
body.is-static .glance-rooms[data-dot="45"] .glance-light { width: 7vmin; height: 7.55vmin; }
body.is-static .glance-rooms[data-dot="38"] .glance-dot { width: 3.8vmin; height: 3.8vmin; }
body.is-static .glance-rooms[data-dot="38"] .glance-light { width: 5.9vmin; height: 6.85vmin; }

/* --- the landing page: the "needs a person" rail beside the tiles (B2, BACKLOG 9.70, 2026-10-03) ----------
   ⭐ The owner's amended B2: a rail of what needs a person, and every tile (no merges; the wall draws 13 of the 21). ⛔ Nothing moves
   between states: the rail is a FIXED size whether it holds seven items or none (its rows are clamped, the
   overflow is "+N more"), and attention is shown IN ADDITION to the tiles, never by reordering them.
   Desktop: a sticky rail beside today's three SECTIONS (the owner kept the category split; no eyebrow per
   tile). Phone: a fixed-height band above them. The wall (static): 13 tiles (the page drops eight there) as one fixed 5x3 grid across the
   whole screen with the rail in the last two cells, sizes in `vmin`, with the explanatory text removed (`.wall-hide`). */
.tiles-b2 { display: grid; grid-template-columns: 18rem minmax(0, 1fr); gap: 1rem; align-items: start; }
.attn { position: sticky; top: .5rem; height: 26rem; overflow: hidden; box-sizing: border-box;
  background: var(--bgColor-layer1); border: 1px solid var(--borderColor-default); border-radius: 10px;
  padding: .8rem; display: flex; flex-direction: column; }
.attn h2 { font-size: 1rem; margin: 0 0 .5rem; flex: none; }
.attn-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .5rem;
  flex: 1; min-height: 0; overflow: hidden; }
.attn-list li { border-left: 3px solid var(--borderColor-emphasis); padding: .2rem .5rem; font-size: .85rem;
  line-height: 1.25; overflow: hidden; flex: none; }
.attn-list li[data-tone="danger"] { border-left-color: var(--fgColor-danger); }
.attn-list li[data-tone="warn"] { border-left-color: var(--fgColor-attention); }
/* ⚠️ The two-line clamp is on the TEXT, not the padded row: `-webkit-line-clamp` on an element with
   vertical padding lets the third line show through the padding. */
.attn-list .attn-text { overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 2;
  -webkit-box-orient: vertical; overflow: hidden; }
.attn-list .attn-age { margin-left: .5em; color: var(--fgColor-muted); }
.attn-quiet { color: var(--fgColor-muted); border-left-color: transparent; }
.attn-more { flex: none; margin: .4rem 0 0; font-size: .85rem; color: var(--fgColor-muted); }
/* ⭐ The rail's rows are links now (page map batch C): the text keeps the row's color and underlines on hover, and the tone stripe stays the signal. */
.attn-list .attn-text a, .attn-more a { color: inherit; text-decoration: none; }
.attn-list .attn-text a:hover, .attn-more a:hover { text-decoration: underline; }
/* The full list below the tiles: the rail's rows, unclipped, in a card. */
.attn-rest { margin-top: 1rem; }
/* ⭐ Page map batch F: an add form starts as a collapsed `<details>` (r-forms), and a rule's Delete (`.danger`) keeps its distance from the everyday Edit beside it (r-confirm). */
details.add-form { margin: .5rem 0 1rem; }
details.add-form > summary { cursor: pointer; font-weight: 600; padding: .35rem 0; }
.rule-delete { margin-inline-start: 1.5rem; }
.attn-rest .attn-list { height: auto; overflow: visible; flex: none; }
.attn-rest .attn-list .attn-text { -webkit-line-clamp: unset; display: block; }
/* ⭐ Page map batch C: a room's scene select sits under its name (`hue_areas.html`), and the Assign page's add form starts collapsed (`hue_assign.html`). */
.area-scene { max-width: 100%; margin-top: .25rem; font-size: .85rem; }
.assign-add { margin: .25rem 0 .25rem .25rem; }
.assign-add > summary { cursor: pointer; font-size: .85rem; color: var(--fgColor-muted); }
.assign-add[open] > summary { margin-bottom: .4rem; }
.tiles-main { min-width: 0; }
.wall-only { display: none; }
@media (max-width: 700px) {
  .tiles-b2 { display: block; }
  /* ⛔ The phone band scrolls instead of clipping (page map batch C): at 11rem a linked item past the second or third row was cut off with no way to reach it. */
  .attn { position: static; height: 11rem; margin-bottom: 1rem; }
  .attn-list { overflow-y: auto; }
}
/* ⭐⭐ The wall (owner, 2026-10-04: "the tiles are too busy on the tv", layout C): pinned to the pane, 13 tiles in one fixed 5x3 grid across the WHOLE screen, and "Needs a
   person" in the last two cells (row 3, columns 4-5) as a two-column list of the same seven rows. The page drops Reflexes, Health, Automation, Credentials, Cost, Spec,
   Backlog and Cameras for a static render, so the 13 tiles fill the 13 cells the rail leaves (the grid auto-places them around it, in document order). The three sections are
   still in the markup (their headings are hidden): size, not a heading, is the grouping. */
body.is-static .tiles-b2 { position: fixed; inset: 0; padding: 1vmin; display: grid; grid-template-columns: repeat(5, minmax(0, 1fr));
  grid-template-rows: repeat(3, minmax(0, 1fr)); gap: 1vmin; align-items: stretch; }
body.is-static .attn { position: static; grid-column: 4 / span 2; grid-row: 3; width: auto; height: auto; align-self: stretch; padding: 1.6vmin 2vmin; border-radius: 1.2vmin; }
body.is-static .attn h2 { font-size: 2.2vmin; margin-bottom: 1vmin; }
body.is-static .attn-list { flex-direction: column; flex-wrap: wrap; align-content: flex-start; gap: .8vmin 1.4vmin; }
body.is-static .attn-list li { width: calc(50% - .7vmin); box-sizing: border-box; font-size: 1.7vmin; padding: .5vmin 1vmin; border-left-width: .5vmin; }
body.is-static .attn-more { font-size: 1.7vmin; margin-top: .6vmin; }
body.is-static .tiles-main { display: contents; }
body.is-static .tiles-main .tile-block, body.is-static .tiles-main .tiles { display: contents; }
body.is-static .tiles-main .tile-section { display: none; }
body.is-static .tiles-main .tile { margin: 0; min-width: 0; min-height: 0; overflow: hidden; padding: 1.1vmin 1.5vmin; }
body.is-static .tiles-main .tile h2 { font-size: 1.9vmin; line-height: 1.15; margin: 0; }
body.is-static .tiles-main .tile .tile-static {
  font-size: 4.2vmin; line-height: 1.05; margin: .3vmin 0; white-space: nowrap; overflow: hidden; text-overflow: clip; }
/* ⭐⭐ Big numbers (owner, 2026-10-03: "how can we make the numbers larger so those are visible across the room?").
   The figure is a VALUE only (digits and the symbols that belong to it: the currency sign, percent, degree, the minus): every word is a unit and
   sits on its own line beneath (`.tile-of`, never inline on the wall). Its size is `--fs`, in vmin, from the
   ARITHMETIC of Noto Sans' advance widths (`app/landing/figures.py`: the widest headline, "100%", is 2.525 em at weight
   300, and a tile is 31.4vmin inside on the 16:9 wall's 5x3 grid, which would allow 12.0vmin with a 2.5% margin; the HEIGHT chose 9.6 (an icon, a title, the figure, its unit and two
   context lines in 29.8vmin). It was 5.7 in a 15.0vmin tile on the 7x3 grid until 2026-10-04). `tests/test_wall_figures.py` holds every format against it, and Chrome measures it. The icon is NEVER taller than the figure's
   digits: its box and its images are 0.714 (the digit height) of `--fs`. */
body.is-static .tiles-main { --fs: 9.6; }
body.is-static .tiles-main .tile .tile-figure { font-size: calc(var(--fs) * 1vmin); font-weight: 300; line-height: 1;
  margin: .3vmin 0 .2vmin; white-space: nowrap; overflow: hidden; text-overflow: clip; }
body.is-static .tiles-main .tile .tile-of:not(.wall-hide) { display: block; font-size: 3.36vmin; line-height: 1.2;
  font-weight: 400; margin: .3vmin 0 0; white-space: nowrap; }
body.is-static .tiles-main .tile .meta { font-size: 2.18vmin; line-height: 1.25; margin: .1vmin 0; }
body.is-static .wall-hide { display: none; }
body.is-static .wall-only { display: inline; }
body.is-static p.wall-only { display: block; }
/* ⛔⛔ A wall section the clip hid STAYS hidden (2026-10-09). Every `body.is-static #wall-X` rule sets a `display`, and an id beats `.is-hidden`, so a page with TWO wall sections (the weather page
   has three) drew the hidden ones over the named one: the Now slide showed the radar and wind. Needs `!important`: no selector short of an inline style outranks an id rule. */
body.is-static .wall-only.is-hidden { display: none !important; }
/* ⭐⭐ The wall's chores slide (BACKLOG 9.69, layout C2; owner 2026-10-09): the section `wall-chores` IS the pane, pinned like the packages slide, sized in `vmin`
   so it means the pane (slides are iframes). Two columns: LATE then THIS WEEK, IN PROGRESS NOW then ANYTIME. ⛔ Nothing here may overflow at 1920x1080: the route
   caps each column at six rows (`chores.wall.SIDE_ROWS`), and a title is ONE line clamped at the column's width by CSS, never by a character count in code.
   Late is in the attention color (a tinted card); the name chip's colors are palette tokens, one per person of the house. */
body.is-static #wall-chores { display: block; position: fixed; inset: 0; margin: 0; padding: 2.2vmin 2.8vmin; box-sizing: border-box; border: 0; border-radius: 0; overflow: hidden; }
body.is-static #wall-chores .wc-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 3.2vmin; height: 100%; }
body.is-static #wall-chores .wc-col { min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: 2.4vmin; }
body.is-static #wall-chores .wc-head { font-size: 3vmin; font-weight: 600; line-height: 1.2; margin: 0 0 1vmin; letter-spacing: .04em; text-transform: uppercase; color: var(--fgColor-muted); }
body.is-static #wall-chores .wc-head[data-tone="warn"] { color: var(--fgColor-attention); }
body.is-static #wall-chores .wc-late { background: var(--bgColor-attention-muted); border-radius: 1.2vmin; padding: 1.6vmin 2vmin; }
body.is-static #wall-chores .wc-list { list-style: none; margin: 0; padding: 0; }
body.is-static #wall-chores .wc-list li { display: flex; align-items: baseline; gap: 1.6vmin; padding: 1.1vmin 0; min-width: 0; }
body.is-static #wall-chores .wc-title { flex: 1 1 auto; min-width: 0; font-size: 4.6vmin; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
body.is-static #wall-chores .wc-due { flex: none; font-size: 2.9vmin; color: var(--fgColor-muted); white-space: nowrap; }
body.is-static #wall-chores .wc-late .wc-due { color: var(--fgColor-attention); }
body.is-static #wall-chores .wc-chip { flex: none; font-size: 2.7vmin; line-height: 1.1; padding: .5vmin 1.4vmin; border-radius: 3vmin; background: var(--bgColor-neutral-muted); color: var(--fgColor-muted); }
body.is-static #wall-chores .wc-chip[data-chip="greg"] { background: var(--bgColor-accent-muted); color: var(--fgColor-accent); }
body.is-static #wall-chores .wc-chip[data-chip="shoe"] { background: var(--bgColor-success-muted); color: var(--fgColor-success); }
body.is-static #wall-chores .wc-more, body.is-static #wall-chores .wc-quiet { font-size: 3vmin; margin: .8vmin 0 0; color: var(--fgColor-muted); }
/* ⭐⭐ The wall's packages slide (BACKLOG 9.69, layout L1; owner 2026-10-09): the section `#wall-packages` IS the pane, pinned like the home tiles, sized in
   `vmin` so it means the pane (slides are iframes). Left about 57% On the way, right about 43% Arrived. ⛔ Nothing here may overflow at 1920x1080: the route caps what
   is drawn (6 on the way, 3 drops) and the drops SHARE the right column's height, each photo at most 30vmin tall. ⭐ **A name prints IN FULL** (owner, 2026-10-11: "why only
   wrap two rows? we're not constrained by empty space"): no line clamp, `overflow-wrap: anywhere` so a long unspaced token still breaks. ⛔ **What keeps it inside the pane is the
   slide's script** (`shipping.html`, "fit"): after layout it hides whole trailing rows, last first, until the column fits, and the server's caps stay as the ceiling. The one
   clamp left is `.wp-clamp`, put on a single row that overflows ALONE (an absurd name), with the line count measured, never a character count in code. */
body.is-static #wall-packages { display: block; position: fixed; inset: 0; margin: 0; padding: 2.2vmin 2.8vmin; box-sizing: border-box; border: 0; border-radius: 0; overflow: hidden; }
body.is-static #wall-packages .wp-cols { display: grid; grid-template-columns: 57fr 43fr; gap: 3.2vmin; height: 100%; }
body.is-static #wall-packages .wp-way, body.is-static #wall-packages .wp-arrived { min-width: 0; min-height: 0; display: flex; flex-direction: column; }
body.is-static #wall-packages .wp-title { font-size: 2.6vmin; font-weight: 400; line-height: 1.2; margin: 0 0 1.2vmin; color: var(--fgColor-muted); flex: none; }
body.is-static #wall-packages .wp-group { font-size: 2.6vmin; font-weight: 600; line-height: 1.2; margin: 1.6vmin 0 .6vmin; flex: none; }
body.is-static #wall-packages .wp-list { list-style: none; margin: 0; padding: 0; }
body.is-static #wall-packages .wp-list li { padding: .5vmin 0; min-width: 0; }
/* ⭐ A name WRAPS as far as it needs (the script above decides how many rows fit); `overflow-wrap: anywhere` breaks a long unspaced token rather than letting it run into the next card (the 57-character name of 2026-10-09). */
body.is-static #wall-packages .wp-name { display: block; font-size: 4vmin; line-height: 1.15; overflow-wrap: anywhere; }
body.is-static #wall-packages .is-hidden { display: none; }
body.is-static #wall-packages .wp-clamp { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: var(--wp-lines, 2); overflow: hidden; }
body.is-static #wall-packages .wp-due { display: block; font-size: 2.4vmin; line-height: 1.25; color: var(--fgColor-muted); }
body.is-static #wall-packages .wp-due[data-tone="warn"], body.is-static #wall-packages .wp-group[data-tone="warn"] { color: var(--fgColor-attention); }
body.is-static #wall-packages .wp-more, body.is-static #wall-packages .wp-quiet { font-size: 2.6vmin; margin: 1.2vmin 0 0; color: var(--fgColor-muted); }
body.is-static #wall-packages .wp-arrived .wp-drop { flex: 1 1 0; min-height: 0; display: flex; gap: 2vmin; align-items: flex-start; margin: 0 0 1.4vmin; }
body.is-static #wall-packages .wp-photo { height: 100%; max-height: 30vmin; width: auto; aspect-ratio: 3 / 4; object-fit: cover; border-radius: .8vmin; flex: none; }
body.is-static #wall-packages .wp-drop-text { min-width: 0; flex: 1 1 auto; }
body.is-static #wall-packages .wp-drop-time { font-size: 2.6vmin; line-height: 1.2; margin: 0 0 .8vmin; color: var(--fgColor-muted); }
body.is-static #wall-packages .wp-names { list-style: none; margin: 0; padding: 0; font-size: 3vmin; line-height: 1.25; }
body.is-static #wall-packages .wp-names li { overflow-wrap: anywhere; padding: .2vmin 0; }
/* The empty state: the whole slide, centered. */
body.is-static #wall-packages .wp-empty { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; }
body.is-static #wall-packages .wp-headline { font-size: 8vmin; font-weight: 300; line-height: 1.1; margin: 0; }
body.is-static #wall-packages .wp-sub { font-size: 2.8vmin; margin: 2.4vmin 0 1.6vmin; color: var(--fgColor-muted); }
body.is-static #wall-packages .wp-empty .wp-drop { display: flex; flex-direction: column; align-items: center; gap: 1.6vmin; min-height: 0; }
body.is-static #wall-packages .wp-empty .wp-photo { height: 40vmin; max-height: 40vmin; }
body.is-static #wall-packages .wp-empty .wp-names { text-align: center; font-size: 3.4vmin; }
/* ⭐ The recent arrivals of an empty "on the way": a FIXED GRID of up to three equal columns (`data-count` is how many), each card `min-width: 0`, so a long name or a card with no photo can never push its neighbors
   around (found against real data 2026-10-09: a photo-less drop sat higher and a 57-character name ran into the next card). Every name prints in full and the script trims the trailing ones to fit the pane, and a
   drop with no photo gets a same-size frame (`wp-photo-empty`) so every card aligns. ⭐ The frame carries a package and "No photo" (owner, 2026-10-11): an empty square read as a picture that failed
   to load. The emoji renders on the appliance (Noto Color Emoji is installed there). */
body.is-static #wall-packages .wp-empty .wp-drops { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4vmin; width: 100%; max-width: 125vmin; justify-items: center; align-items: start; min-height: 0; }
body.is-static #wall-packages .wp-empty .wp-drops[data-count="1"] { grid-template-columns: minmax(0, 1fr); max-width: 60vmin; }
body.is-static #wall-packages .wp-empty .wp-drops[data-count="2"] { grid-template-columns: repeat(2, minmax(0, 1fr)); max-width: 110vmin; }
body.is-static #wall-packages .wp-empty .wp-drops .wp-drop { width: 100%; min-width: 0; align-items: center; }
body.is-static #wall-packages .wp-empty .wp-drops .wp-photo { height: 34vmin; max-height: 34vmin; width: auto; aspect-ratio: 3 / 4; flex: none; }
body.is-static #wall-packages .wp-photo-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1.2vmin; box-sizing: border-box; border: .35vmin solid var(--borderColor-default); border-radius: .8vmin; }
body.is-static #wall-packages .wp-photo-glyph { font-size: 7vmin; line-height: 1; opacity: .6; }
body.is-static #wall-packages .wp-photo-note { font-size: 2.2vmin; line-height: 1.2; color: var(--fgColor-muted); }
body.is-static #wall-packages .wp-empty .wp-drops .wp-drop-time { margin: 0; text-align: center; }
body.is-static #wall-packages .wp-empty .wp-drops .wp-names { width: 100%; min-width: 0; font-size: 2.8vmin; }
body.is-static #wall-packages .wp-empty .wp-drops .wp-names li { text-align: center; }
/* ⭐ The food locations' icons (`_food_icons.html`): `currentColor` strokes in a 24-unit box sized by the font, so a row, a badge and the wall's legend share one drawing; the `mark` is a small
   inverted badge on the lower right. */
.fi { position: relative; display: inline-flex; flex: none; width: 1.5em; height: 1.5em; vertical-align: -.35em; }
.fi svg { width: 100%; height: 100%; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
.fi svg.fi-real { stroke: none; }          /* ⭐ the real appliances carry their own finishes: no outline stroke inherited from `.fi svg` */
.fi-mark { position: absolute; right: -.35em; bottom: -.25em; min-width: 1.05em; height: 1.05em; padding: 0 .1em; box-sizing: border-box; border-radius: 1em; background: var(--fgColor-default); color: var(--bgColor-default); font-size: .62em; font-weight: 700; line-height: 1.05em; text-align: center; }
/* ⭐⭐ The wall's grocery slide (BACKLOG 9.69, layout G2; owner 2026-10-09): the section `wall-grocery` IS the pane, pinned like the other wall slides and sized in `vmin`.
   Two columns balanced by rows (the route puts each store's section in the column with fewer rows), at most seven rows a column; one large line per item. ⛔ Nothing
   here may overflow at 1920x1080 and an item is ONE line clamped at the column's width by CSS, never by a character count in code. */
body.is-static #wall-grocery { display: block; position: fixed; inset: 0; margin: 0; padding: 2.2vmin 2.8vmin; box-sizing: border-box; border: 0; border-radius: 0; overflow: hidden; }
body.is-static #wall-grocery .wg-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 3.2vmin; height: 100%; }
body.is-static #wall-grocery .wg-col { min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: 2.4vmin; }
body.is-static #wall-grocery .wg-head { font-size: 3vmin; font-weight: 600; line-height: 1.2; margin: 0 0 1vmin; letter-spacing: .04em; text-transform: uppercase; color: var(--fgColor-muted); }
body.is-static #wall-grocery .wg-list { list-style: none; margin: 0; padding: 0; }
body.is-static #wall-grocery .wg-list li { display: flex; align-items: baseline; gap: 1.4vmin; padding: 1vmin 0; min-width: 0; }
body.is-static #wall-grocery .wg-text { min-width: 0; font-size: 4.6vmin; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
body.is-static #wall-grocery .wg-recur { flex: none; font-size: 2.6vmin; color: var(--fgColor-muted); }
/* ⭐ The BRAND CELL (owner, 2026-10-11): ONE fixed-width square at the start of every row so the names line up. An icon is an `<img>` in FULL color (no filter) contained in the square; the letter circle and the
   dash circle are muted outlines in `currentColor`. */
body.is-static #wall-grocery .wg-brand { flex: none; display: flex; width: 3.8vmin; height: 3.8vmin; color: var(--fgColor-muted); }
.fi-logo { background-repeat: no-repeat; background-position: center; background-size: contain; }
.fi-brand { display: inline-flex; width: 1.3em; height: 1.3em; margin-right: .45em; vertical-align: -.3em; color: var(--fgColor-muted); }
.food-open { font-size: .9em; margin-right: .1em; }
/* ⭐ A feed glyph drawn as one of the food icons (`feed_glyphs` filter, owner 2026-10-18): sized to the line's own text, and its glyph kept in a visually hidden span so a search still finds it. Tokens only: the real appliance icons carry their own finishes. */
.fg { display: inline-block; line-height: 1; }
.fg .fi { width: 1.15em; height: 1.15em; vertical-align: -.15em; }
/* ⭐ A feed glyph drawn from Discord's CDN (BACKLOG 9.91, owner 2026-10-20): the picture at the size of the SVG icons, with its fallback (the key's SVG, else the glyph) hidden beside it until `base.html`'s delegated `error` listener marks the picture failed (`.fg-failed`). No inline `onerror`. */
.fg-cdn .fg-cdn-img { width: 1.15em; height: 1.15em; vertical-align: -.15em; object-fit: contain; }
.fg-cdn .fg-fallback { display: none; }
.fg-cdn.fg-failed .fg-cdn-img { display: none; }
.fg-cdn.fg-failed .fg-fallback { display: inline; }
/* ⭐ A light dot of the feed drawn as the UI's own light indicator (`feed_glyphs` filter, owner 2026-10-18): the rim and fill carry the light's color as SVG attributes, so nothing here sets a color. */
.fg-light .light-dot { width: 1.05em; height: 1.05em; vertical-align: -.15em; }
body.is-static #wall-grocery .wg-brand > img, body.is-static #wall-grocery .wg-brand > svg, .fi-brand > img, .fi-brand > svg { width: 100%; height: 100%; object-fit: contain; }
body.is-static #wall-grocery .wg-more { font-size: 3vmin; margin: .8vmin 0 0; color: var(--fgColor-muted); }
body.is-static #wall-grocery .wg-headline { font-size: 8vmin; font-weight: 300; line-height: 1.1; margin: 0; position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
/* ⭐⭐ Layout A (owner, 2026-10-11): with food open the section is a SPLIT, the stores stacked in the left THIRD and "Expiring next" in the right TWO THIRDS (owner, same day, after seeing it
   live: "the cutoff is significant"), a thin divider between. A food row is ONE line: the name (clamped by CSS), its muted place, and the short date (`data-tone="warn"` past, today and
   tomorrow). ⛔ Nothing may overflow at 1920x1080. */
body.is-static #wall-grocery .wg-split { grid-template-columns: 1fr 2fr; gap: 0; }
body.is-static #wall-grocery .wg-split > .wg-col { padding: 0 3.2vmin; }
body.is-static #wall-grocery .wg-split > .wg-col:first-child { padding-left: 0; }
body.is-static #wall-grocery .wg-split > .wg-col:last-child { padding-right: 0; border-left: .2vmin solid var(--borderColor-default); }
body.is-static #wall-grocery .wg-none { font-size: 3vmin; margin: 0; color: var(--fgColor-muted); }
body.is-static #wall-grocery .wg-food .wg-list li { gap: 1.8vmin; align-items: center; padding: .7vmin 0; }
body.is-static #wall-grocery .wg-food .wg-text { flex: 1 1 auto; font-size: 4vmin; }
body.is-static #wall-grocery .wg-open { flex: none; font-size: 3.4vmin; line-height: 1; margin-right: -.6vmin; }
/* ⭐ A highlighted row (`hi` in the notes, owner 2026-10-17) reads from across the room: an accent-tinted band with a 1vmin marker on its left. The negative margins and equal padding keep the name, place and date in the SAME columns as a plain row. */
body.is-static #wall-grocery .wg-food .wg-list li.is-high { background: var(--bgColor-accent-muted); box-shadow: inset 1vmin 0 0 var(--fgColor-accent); border-radius: .6vmin; margin: 0 -1.4vmin 0 -2.8vmin; padding-left: 2.8vmin; padding-right: 1.4vmin; }
body.is-static #wall-grocery .wg-label { margin-bottom: 1.6vmin; }
/* ⭐ A place is an ICON (`_food_icons.html`), in its own narrow column so the name keeps the width; the legend along the bottom of the food names every place. ⛔ No smaller than 2.2vmin. */
body.is-static #wall-grocery .wg-place { flex: none; width: 8vmin; font-size: 3.4vmin; color: var(--fgColor-muted); display: flex; justify-content: center; }
/* ⭐ ONE row, always (owner, 2026-10-11: the TV's wider font wrapped "Pantry cabinets" onto a second line): `nowrap`, the SPACE between entries is what gives (`space-between` with a small minimum gap),
   and ⛔ the TEXT STAYS at 2.4vmin. Only if it still cannot fit does a label end in an ellipsis, never a second row and never a smaller size. */
body.is-static #wall-grocery .wg-legend { list-style: none; margin: auto 0 0; padding: 1.6vmin 0 0; display: flex; flex-wrap: nowrap; justify-content: space-between; gap: 0 1vmin; font-size: 2.4vmin; line-height: 1.2; color: var(--fgColor-muted); border-top: .2vmin solid var(--borderColor-default); }
body.is-static #wall-grocery .wg-legend li { display: flex; align-items: center; gap: .8vmin; white-space: nowrap; flex: 0 1 auto; min-width: 0; }
body.is-static #wall-grocery .wg-legend li > span:last-child { overflow: hidden; text-overflow: ellipsis; }
body.is-static #wall-grocery .wg-when { flex: none; min-width: 14vmin; text-align: right; font-size: 3vmin; color: var(--fgColor-muted); white-space: nowrap; }
body.is-static #wall-grocery .wg-when[data-tone="warn"] { color: var(--fgColor-attention); font-weight: 600; }
/* ⭐⭐ The wall's Now slide (BACKLOG 9.69, owner 2026-10-09; mockup W7): the section `wall-weather-now` IS the pane, pinned like the other wall slides and sized in `vmin`.
   A strip (alerts, hazards above their threshold), the current conditions beside a 24 hour chart, six hazard tiles, the forecaster's key message. A tile's color is
   its tone (`data-tone`), never a class built from a value. ⛔ Nothing here may overflow at 1920x1080: every line is one line, clamped by CSS. */
body.is-static #wall-weather-now { display: flex; flex-direction: column; gap: 2vmin; position: fixed; inset: 0; margin: 0; padding: 2.6vmin 3.5vmin; box-sizing: border-box; border: 0; border-radius: 0; overflow: hidden; }
body.is-static #wall-weather-now .ww-strip { display: flex; flex-wrap: wrap; gap: 1.6vmin; flex: none; min-height: 5.6vmin; }
body.is-static #wall-weather-now .ww-chip { font-size: 2.4vmin; font-weight: 600; padding: .4vmin 1.4vmin; border: .18vmin solid var(--fgColor-muted); border-radius: .9vmin; color: var(--fgColor-default); white-space: nowrap; }
body.is-static #wall-weather-now .ww-chip[data-tone="warn"] { border-color: var(--fgColor-attention); color: var(--fgColor-attention); }
body.is-static #wall-weather-now .ww-chip[data-tone="danger"] { border-color: var(--fgColor-danger); color: var(--fgColor-danger); }
body.is-static #wall-weather-now .ww-main { display: grid; grid-template-columns: 32fr 68fr; grid-template-rows: minmax(0, 1fr); gap: 3vmin; flex: 1; min-height: 0; }
body.is-static #wall-weather-now .ww-now, body.is-static #wall-weather-now .ww-chart { background: var(--bgColor-inset); border: .15vmin solid var(--borderColor-default); border-radius: 1.4vmin; padding: 1.8vmin 2.4vmin; min-width: 0; min-height: 0; box-sizing: border-box; }
body.is-static #wall-weather-now .ww-now { display: flex; flex-direction: column; justify-content: center; }
body.is-static #wall-weather-now .ww-temp { font-size: 19vmin; font-weight: 300; line-height: .9; }
body.is-static #wall-weather-now .ww-sky { font-size: 5vmin; font-weight: 600; line-height: 1.15; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
body.is-static #wall-weather-now .ww-facts { font-size: 2.8vmin; line-height: 1.55; margin-top: 1.4vmin; color: var(--fgColor-muted); }
body.is-static #wall-weather-now .ww-facts > div { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
body.is-static #wall-weather-now .ww-chart { display: flex; flex-direction: column; }
body.is-static #wall-weather-now .ww-chart-title { font-size: 2.6vmin; font-weight: 600; letter-spacing: .03em; text-transform: uppercase; color: var(--fgColor-muted); margin-bottom: 1vmin; }
body.is-static #wall-weather-now .ww-svg { flex: 1 1 0; min-height: 0; width: 100%; }
body.is-static #wall-weather-now .ww-night { fill: var(--bgColor-default); }
body.is-static #wall-weather-now .ww-band-label { fill: var(--fgColor-muted); font-size: 22px; }
body.is-static #wall-weather-now .ww-nowline { stroke: var(--borderColor-muted); stroke-width: 2; stroke-dasharray: 4 6; }
body.is-static #wall-weather-now .ww-line { fill: none; stroke: var(--fgColor-attention); stroke-width: 5; stroke-linejoin: round; }
body.is-static #wall-weather-now .ww-value { fill: var(--fgColor-default); font-size: 26px; font-weight: 600; }
body.is-static #wall-weather-now .ww-axis { fill: var(--fgColor-muted); font-size: 22px; }
body.is-static #wall-weather-now .ww-gap { font-size: 3vmin; color: var(--fgColor-muted); }
body.is-static #wall-weather-now .ww-tiles { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 1.4vmin; flex: none; }
body.is-static #wall-weather-now .ww-tile { background: var(--bgColor-inset); border: .15vmin solid var(--borderColor-default); border-left: .9vmin solid var(--borderColor-muted); border-radius: 1.4vmin; padding: 1.3vmin 1.6vmin; min-width: 0; box-sizing: border-box; }
body.is-static #wall-weather-now .ww-tile[data-tone="warn"] { border-left-color: var(--fgColor-attention); }
body.is-static #wall-weather-now .ww-tile[data-tone="danger"] { border-left-color: var(--fgColor-danger); }
body.is-static #wall-weather-now .ww-label { font-size: 2.2vmin; color: var(--fgColor-muted); }
body.is-static #wall-weather-now .ww-level { font-size: 3.4vmin; font-weight: 700; margin-top: .3vmin; line-height: 1.15; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
body.is-static #wall-weather-now .ww-when { font-size: 2.6vmin; font-weight: 600; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
body.is-static #wall-weather-now .ww-detail { font-size: 1.9vmin; margin-top: .4vmin; line-height: 1.25; min-height: 4.8vmin; color: var(--fgColor-muted); overflow: hidden; }
body.is-static #wall-weather-now .ww-tile[data-tone="warn"] .ww-level, body.is-static #wall-weather-now .ww-tile[data-tone="warn"] .ww-when { color: var(--fgColor-attention); }
body.is-static #wall-weather-now .ww-tile[data-tone="danger"] .ww-level, body.is-static #wall-weather-now .ww-tile[data-tone="danger"] .ww-when { color: var(--fgColor-danger); }
body.is-static #wall-weather-now .ww-message { font-size: 3vmin; line-height: 1.25; flex: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* ⭐⭐ The wall's Forecast slide (BACKLOG 9.69, mockup W8): the section `wall-forecast` IS the pane. Each row is a day and the night after it as ONE range bar on a shared scale
   (the route computes `left` and `width` as percents of that scale), then the overnight line, then the narrative. ⛔ Every line is ONE line, clamped by CSS. */
body.is-static #wall-forecast { display: flex; flex-direction: column; gap: 1.6vmin; position: fixed; inset: 0; margin: 0; padding: 2.2vmin 3.5vmin; box-sizing: border-box; border: 0; border-radius: 0; overflow: hidden; }
body.is-static #wall-forecast .wf-rows { list-style: none; margin: 0; padding: 0; flex: 1; min-height: 0; display: flex; flex-direction: column; justify-content: space-between; }
body.is-static #wall-forecast .wf-row { border-bottom: .12vmin solid var(--borderColor-default); padding: .6vmin 0; min-height: 0; }
body.is-static #wall-forecast .wf-main { display: grid; grid-template-columns: 6vmin 17vmin 8vmin minmax(0, 1fr) 9vmin 15vmin 40vmin; align-items: center; gap: 1.6vmin; }
/* ⭐ The sky icons are sized in vmin like the home tiles' (`.tile-icon img`), never from their own artwork. ⛔ Nothing here dims an image: the night rules are
   colors on text only (`test_the_wall_pictures_fill_their_halves_and_are_never_dimmed`). */
body.is-static #wall-forecast .wf-sky { display: flex; align-items: center; justify-content: center; }
body.is-static #wall-forecast .wf-sky img { display: block; width: 6vmin; height: 6vmin; }
body.is-static #wall-forecast .wf-night img { display: inline-block; vertical-align: middle; width: 3.6vmin; height: 3.6vmin; margin: -.4vmin .2vmin 0; }
body.is-static #wall-forecast .wf-day { font-size: 5vmin; font-weight: 700; line-height: 1.1; white-space: nowrap; }
body.is-static #wall-forecast .wf-low { font-size: 3.6vmin; color: var(--fgColor-muted); text-align: right; }
body.is-static #wall-forecast .wf-high { font-size: 5vmin; font-weight: 700; }
body.is-static #wall-forecast .wf-track { display: block; width: 100%; height: 1.8vmin; border-radius: 1vmin; background: var(--bgColor-inset); }
/* The bar is an SVG line on the shared 0-100 scale (the route computes its ends), with a non-scaling stroke so the round caps stay round when the track stretches. */
body.is-static #wall-forecast .wf-bar { stroke: var(--fgColor-attention); stroke-width: 1.8vmin; stroke-linecap: round; vector-effect: non-scaling-stroke; }
body.is-static #wall-forecast .wf-rain { font-size: 3.4vmin; color: var(--fgColor-muted); white-space: nowrap; }
body.is-static #wall-forecast .wf-heat { font-size: 2.8vmin; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
body.is-static #wall-forecast .wf-heat:not(:empty) { padding: .5vmin 1.4vmin; border: .18vmin solid var(--fgColor-muted); border-radius: .9vmin; }
body.is-static #wall-forecast .wf-heat[data-tone="warn"] { border-color: var(--fgColor-attention); color: var(--fgColor-attention); }
body.is-static #wall-forecast .wf-heat[data-tone="danger"] { border-color: var(--fgColor-danger); color: var(--fgColor-danger); }
body.is-static #wall-forecast .wf-night { font-size: 3vmin; margin: .2vmin 0 0 33.2vmin; color: var(--fgColor-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
body.is-static #wall-forecast .wf-narrative { flex: none; background: var(--bgColor-inset); border: .15vmin solid var(--borderColor-default); border-radius: 1.4vmin; padding: 1.4vmin 2.4vmin; }
body.is-static #wall-forecast .wf-narrative p { margin: .3vmin 0; font-size: 3vmin; line-height: 1.3; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* ⭐⭐ The wall's Radar and wind slide (BACKLOG 9.69, mockup W6): the section `wall-radar-wind` IS the pane - two halves, each picture as large as its half allows, a label laid
   on it. The radar is a square stack (basemap, reflectivity, rings), so it takes the half's width and sits in the middle; the wind picture is the property drawing, CROPPED at
   its edges to fill the half (the owner: "your updated radar/wind slide was fine"). ⚠️ Images are never dimmed at night. */
body.is-static #wall-radar-wind { display: grid; grid-template-columns: 1fr 1fr; gap: 1.4vmin; position: fixed; inset: 0; margin: 0; padding: 1.2vmin; box-sizing: border-box; border: 0; border-radius: 0; overflow: hidden; }
body.is-static #wall-radar-wind .wrw-half { position: relative; min-width: 0; min-height: 0; overflow: hidden; border-radius: 1.4vmin; background: var(--bgColor-inset); display: flex; align-items: center; justify-content: center; }
body.is-static #wall-radar-wind .wx-radar { width: min(100%, 100vh); max-width: none; margin: 0; aspect-ratio: 1; flex: none; }
body.is-static #wall-radar-wind .site-plan { position: absolute; inset: 0; width: 100%; height: 100%; max-width: none; border-radius: 0; }
body.is-static #wall-radar-wind .wrw-label { position: absolute; left: 1.4vmin; top: 1.4vmin; font-size: 4.2vmin; line-height: 1.2; padding: .4vmin 1.6vmin; border-radius: 1vmin; background: var(--bgColor-default); color: var(--fgColor-default); white-space: nowrap; max-width: calc(100% - 2.8vmin); overflow: hidden; text-overflow: ellipsis; }
/* ⭐⭐ The wall's NIGHT version of the weather slides (owner, 2026-10-09: "night version can just be dimmer text, no roku"). The page carries `?night=1` when the shell is at night, and
   `body.is-night` lowers the TEXT and ACCENT colors of the three wall sections to about 60% toward the page background: it swaps the day tokens for their `-night` versions (`_tokens.css`) ON the section,
   and keeps copies on the body (`--night-*`) so an elevated element can take the day tokens back (a custom property cannot be redefined in terms of itself on one element). ⚠️ Images are never dimmed (they are not text), and an element
   marked `data-elevated` - the strip, a tile at or above its threshold - takes the full tokens back, so an active alert is as loud at 2 AM as at noon. */
body.is-night { --night-fg: var(--fgColor-default); --night-muted: var(--fgColor-muted); --night-warn: var(--fgColor-attention); --night-danger: var(--fgColor-danger); --night-accent: var(--fgColor-accent); }
body.is-night #wall-weather-now, body.is-night #wall-forecast, body.is-night #wall-radar-wind {
  --fgColor-default: var(--fgColor-default-night);
  --fgColor-muted: var(--fgColor-muted-night);
  --fgColor-attention: var(--fgColor-attention-night);
  --fgColor-danger: var(--fgColor-danger-night);
  --fgColor-accent: var(--fgColor-accent-night);
  color: var(--fgColor-default); }
body.is-night #wall-weather-now [data-elevated] { --fgColor-default: var(--night-fg); --fgColor-muted: var(--night-muted); --fgColor-attention: var(--night-warn); --fgColor-danger: var(--night-danger); --fgColor-accent: var(--night-accent); color: var(--night-fg); }
/* ⭐ Icons on the wall, ABOVE a small muted title (`app/landing/icons.py`; owner 2026-10-03: "those are there for
   reference/clarification, readability across the room isnt critical for that text"). Desktop and phone have no icon.
   The icon is a FIXED BOX holding SVG images (Noto Emoji, served by us: the same picture on every machine), never sized
   from the artwork (the lights lesson, #1618); the image is 0.63 of the box's height. The two-image states (both
   laundry machines, an open door) use a smaller image so the pair fits the box's width. The title is the context
   lines' size (2.18vmin on the 5x3 grid; it was the wall's 1.3vmin floor on the 7x3 one), muted, on ONE line in a fixed one-line box (owner: the point is every
   icon in a row starting at the same y); the ellipsis is a last resort that must never trigger. */
.tile-icon { display: none; }
body.is-static .tiles-main .tile > h2 { font-size: 2.18vmin; font-weight: 400; line-height: 1.25; height: 2.73vmin;
  margin: 0 0 1vmin; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; color: var(--fgColor-muted); }
body.is-static .tiles-main .tile > h2 a { color: inherit; text-decoration: none; }
body.is-static .tiles-main .tile p.tile-icon { display: flex; align-items: flex-start; justify-content: flex-start; gap: .4vmin;
  box-sizing: border-box; width: 100%; height: calc(var(--fs) * 0.714vmin); margin: 0 0 .6vmin; overflow: hidden; }
/* ⭐ SVG images (`/emoji/<name>.svg`), sized to the figure's DIGIT HEIGHT (0.714 em) and never from their own artwork:
   one picture or two (both laundry machines, an open door), each a square of that height. */
body.is-static .tiles-main .tile p.tile-icon img { flex: none; width: calc(var(--fs) * 0.714vmin); height: calc(var(--fs) * 0.714vmin); }
/* ⭐ The Lights tile's icon is the shared light DOT (an SVG), in the same fixed box and the same digit-height square as an emoji, so no tile moves. The off and fault colors are
   set here (the rings stroke currentColor) from the icon's own `data-state`, which the live patch keeps in step with the count. */
body.is-static .tiles-main .tile p.tile-icon svg.tile-dot { flex: none; width: calc(var(--fs) * 0.714vmin); height: calc(var(--fs) * 0.714vmin); }
p.tile-icon[data-state="off"] svg.tile-dot { color: var(--borderColor-emphasis); }
p.tile-icon[data-state="unreachable"] svg.tile-dot { color: var(--fgColor-attention); }
/* one context line, and only when a person would act on it: the extras (30-day counts, other rooms,
   "coordinator reported ...") are hidden on the wall unless the template marks one `wall-keep` or it is toned */
body.is-static .tile-extra:not(.wall-keep):not([data-tone]) { display: none; }
body.is-static .tile-extra.wall-keep { display: block; }
body.is-static #tiles ~ * { display: none; }

/* --- the climate tiles: one temperature per room, readable from a sofa ----
   Desk sizing first; the wall overrides are further down. */
.climate-tiles {
  display: grid;
  /* ⭐ `auto-fill`, not `auto-fit` (2026-09-25): with two groups, `auto-fit`
     stretched the three appliance tiles to twice the width of the five room
     tiles above them. Filling keeps one tile width down the page. */
  grid-template-columns: repeat(auto-fill, minmax(min(11rem, 100%), 1fr));
  gap: .8rem;
}
.climate-group + .climate-group { margin-top: 1.4rem; }
.climate-tile { text-align: center; }
/* ⭐ **The desk: one tile per row, each chart the width of the page** (owner, 2026-10-06: "stretch each chart across the entire page
   width so the trend is more visible"). The name, the reading and its details share the first line; every chart below it takes the full
   row. ⚠️ Desk only: the wall lays its tiles out by count (`data-cols`, below) and draws the rooms as one chart per story anyway. */
body:not(.is-static) .climate-tiles { grid-template-columns: 1fr; }
body:not(.is-static) .climate-tile { display: flex; flex-wrap: wrap; align-items: baseline; column-gap: 1.2rem; text-align: left; }
body:not(.is-static) .climate-tile > * { margin-top: 0; margin-bottom: 0; }
body:not(.is-static) .climate-tile > .chart,
body:not(.is-static) .climate-tile > .meta { flex: 0 0 100%; }
body:not(.is-static) .climate-tile > .chart { margin-top: .6rem; }

/* The oven's tile in the appliances section (Climate page, every render). "Off" is the normal state and is muted, not a warning. */
.oven-chart { width: 100%; height: 7rem; display: block; margin-top: 0.75rem; }
.oven-line { fill: none; stroke-width: 2; stroke-linejoin: round; vector-effect: non-scaling-stroke; }
.oven-line--oven { stroke: var(--fgColor-accent); }
.oven-line--food { stroke: var(--fgColor-muted); }
.oven-dot { stroke: none; }
.oven-dot.oven-line--oven { fill: var(--fgColor-accent); }
.oven-dot.oven-line--food { fill: var(--fgColor-muted); }
.oven-off { color: var(--fgColor-muted); }
.climate-where {
  color: var(--fgColor-muted);
  font-size: .8rem;
  margin: 0 0 .2rem;
  /* A room name is short; two lines then clipped keeps every tile the same
     height so the numbers line up across the grid. */
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
.climate-temp {
  font-size: 2.1rem;
  line-height: 1.05;
  font-weight: 600;
  margin: 0;
  color: var(--fgColor-default);
}
.climate-unit {
  font-size: .45em;
  font-weight: 400;
  color: var(--fgColor-muted);
  margin-left: .1em;
}
/* An installed sensor with no reading yet: present, and visibly not a number. */
.climate-quiet { color: var(--fgColor-muted); font-weight: 400; }
/* ⭐ A device's TEST window on the desk's sparkline: dotted and muted, the solid line resuming after (2026-10-06). */
.chart-line--test { stroke-dasharray: 1 4; stroke-linecap: round; opacity: 0.8; }
.climate-detail {
  color: var(--fgColor-muted);
  font-size: .72rem;
  margin: .25rem 0 0;
  display: flex;
  flex-wrap: wrap;
  gap: .1rem .5rem;
  justify-content: center;
}

/* ⛔⛔ **The wall, in vmin - and rem cannot do this job.** The root font size
   is 16px whether the box is a phone or a 4K television, so a rem-sized slide
   occupies the top tenth of the panel and leaves the rest black. That shipped
   once on the lights view and had to be measured to be believed.
   ⭐ `position: fixed; inset: 0` and nothing else: `height: 100%` needs an
   unbroken chain of sized ancestors, and a `100vh` box in normal flow is a
   viewport TALL starting wherever its ancestors put it. Slides are iframes,
   so the pane document's viewport IS its box. */
/* ⛔⛔ **Rewritten 2026-09-25, when eight sensors met a grid sized for four.**
   The grid was `minmax(40vw, 1fr)`, which lands four tiles 2x2 and eight in
   four rows of two, each a quarter of the panel tall and clipped. Each group
   is now its own slide (`rotation.SLIDES`), and the ROUTE picks its columns
   from its count (`_climate_group`): 3 appliance tiles in one row, 5 rooms as
   3 + 2. ⭐ Flex rather than grid, so a short last row CENTERS instead of
   leaving an empty cell at its end. */
body.is-static .climate-tiles {
  position: fixed;
  inset: 0;
  padding: 2vmin;
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  /* ⭐ Lines share the height equally, so two rows are two halves: the
     height the charts below were measured to fit. */
  align-content: stretch;
  gap: 2vmin;
  overflow: hidden;
  --climate-cols: 2;
}
/* ⚠️ An attribute, not a class: a class built from a template variable is
   one the cascade guards cannot resolve to a rule. */
body.is-static .climate-tiles[data-cols="1"] { --climate-cols: 1; }
body.is-static .climate-tiles[data-cols="3"] { --climate-cols: 3; }
body.is-static .climate-tiles[data-cols="4"] { --climate-cols: 4; }
body.is-static .climate-tile {
  display: flex;
  flex-direction: column;
  justify-content: center;
  /* ⚠️ The basis is the column share less its share of the gaps. The tile's
     HEIGHT comes from the flex line it stretches to fill, which is what
     centers the reading in the tile rather than high in it. */
  flex: 0 0 calc((100% - (var(--climate-cols) - 1) * 2vmin) / var(--climate-cols));
  margin: 0;
}
body.is-static .climate-where { font-size: 2.6vmin; margin-bottom: .6vmin; }
body.is-static .climate-temp { font-size: 11vmin; }
body.is-static .climate-detail { font-size: 1.9vmin; margin-top: 1vmin; }

/* ⭐⭐ The trend on the wall. ⚠️ Sized in `vmin` and given real HEIGHT rather
   than inheriting the desk sparkline's: the surviving half of the old
   desk-only argument is that a sparkline read from eight feet is a smudge,
   and the answer to that is a bigger line, not a hidden one.
   ⛔ `preserveAspectRatio="none"` on the SVG means the viewBox stretches to
   whatever box this sets, so the height here is the real control. */
body.is-static .chart { margin-top: 1.4vmin; }
body.is-static .chart-plot {
  width: 100%;
  /* ⚠️ Measured against a 4K render: at 9vmin the tile still had a quarter of
     its height empty below the plot. The constraint on this number is the
     temperature, which must stay the thing you see first from across the
     room - not the space, which there is plenty of.
     ⚠️ 11vmin since 2026-09-25: the humidity chart below took the space
     (`.chart-humidity`), and at 13 the bottom row clipped. */
  height: 11vmin;
}
/* ⛔ **Not the desk accent.** `--fgColor-accent` is an indigo that reads fine
   on a monitor at arm's length and turns into a dim smudge on a 65-inch panel
   across a room - confirmed on the 4K render before this line existed. The
   wall gets the same foreground the temperature uses, at a weight that
   survives the distance. ⚠️ `vector-effect: non-scaling-stroke` is inherited
   from the desk rule and is what makes this a real pixel width despite
   `preserveAspectRatio="none"` stretching the viewBox. */
body.is-static .chart-line {
  stroke: var(--fgColor-default);
  stroke-width: 3;
}
/* ⚠️ The floor has to stay VISIBLY subordinate to the line, or from eight
   feet the reader sees two lines and no trend. */
body.is-static .chart-frame { stroke: var(--borderColor-muted); }
/* ⭐ Humidity is the SECOND line on a climate tile, so it is shorter than the
   temperature's and drawn muted: the temperature stays the thing seen first.
   ⛔⛔ **Measured, not estimated - the first sizing CLIPPED the wall.** An 8vmin
   plot plus a caption under the 13vmin temperature chart grew each tile from
   527 to 575 px (of 1125) on the 4K capture of 2026-09-25, and the bottom row's
   humidity chart and caption ran off the panel: a 2x2 wall has no scroll to
   absorb an overflow. So on the wall the temperature chart gives up 2vmin
   (the rule above), this plot is 6vmin, and the caption goes - the muted
   shorter line reads as the second series, and the tile's detail line
   already prints the % RH. ⚠️ Re-capture after ANY change here. */
.chart-humidity .chart-line { stroke: var(--fgColor-muted); }
body.is-static .chart-humidity { margin-top: .8vmin; }
body.is-static .chart-humidity .chart-plot { height: 6vmin; }
body.is-static .chart-humidity .chart-line { stroke: var(--fgColor-muted); }
body.is-static .chart-humidity .chart-span { display: none; }

/* --- the wall's climate charts (2026-10-04): the rooms chart and the appliance lanes -----------------------------------------
   ⭐ One SVG per slide, 1600 x 900 units, `meet`-scaled and fixed to the panel: text and shapes scale TOGETHER, so nothing is stretched.
   Every color is a token (the guards forbid a literal here); the eight room colors REUSE the semantic tokens in an order that
   keeps red (danger) for the sixth room, and `.rc-c<n>` sets `--rc` for everything inside it. `web/climate_chart` builds the geometry. */
.rc-svg { display: block; width: 100%; max-width: 100%; }
body.is-static .rc-svg { position: fixed; inset: 0; height: 100%; }
/* ⭐ On the DESK the same charts sit in the page's column, above the tiles: full width, the height from the viewBox (never fixed; the wall rule above is `body.is-static` only). A
   test window is dotted and muted, as the tiles' sparklines draw it (2026-10-06). */
.climate-desk-chart .rc-svg { height: auto; }
.climate-desk-chart { margin-bottom: 1.5rem; }
.rc-line--test { stroke-dasharray: 2 7; stroke-linecap: round; opacity: 0.8; }
.rc-svg [data-c="0"] { --rc: var(--chart-0); }
.rc-svg [data-c="1"] { --rc: var(--chart-1); }
.rc-svg [data-c="2"] { --rc: var(--chart-2); }
.rc-svg [data-c="3"] { --rc: var(--chart-3); }
.rc-svg [data-c="4"] { --rc: var(--chart-4); }
.rc-svg [data-c="5"] { --rc: var(--chart-5); }
.rc-svg [data-c="6"] { --rc: var(--chart-6); }
.rc-svg [data-c="7"] { --rc: var(--fgColor-muted); }
.rc-svg text { font-family: inherit; }
.rc-night { fill: var(--bgColor-field); opacity: .55; }
.rc-grid { stroke: var(--borderColor-default); stroke-width: 1; }
.rc-frame { fill: none; stroke: var(--borderColor-muted); stroke-width: 1.2; }
.rc-tick { fill: var(--fgColor-muted); }
.rc-line { fill: none; stroke: var(--rc, var(--fgColor-default)); stroke-width: 2.8; stroke-linejoin: round; stroke-linecap: round; }
.rc-line-thin { stroke-width: 2; }
/* ⭐ The ROOMS chart only (`data-rooms`; owner, 2026-10-10): muted, equal-weight room lines (width 2.6 and the `--chart-room-N` colors), so the thermostat is the one thing that stands out. The appliance lanes
   carry no `data-rooms` and keep `.rc-line` and the semantic `--chart-N` above. A rule per index, mapped by `data-c`. */
.rc-svg[data-rooms] .rc-line { stroke-width: 2.6; }
.rc-svg[data-rooms] .rc-line-thin { stroke-width: 2; }
.rc-svg[data-rooms] [data-c="0"] { --rc: var(--chart-room-0); }
.rc-svg[data-rooms] [data-c="1"] { --rc: var(--chart-room-1); }
.rc-svg[data-rooms] [data-c="2"] { --rc: var(--chart-room-2); }
.rc-svg[data-rooms] [data-c="3"] { --rc: var(--chart-room-3); }
.rc-svg[data-rooms] [data-c="4"] { --rc: var(--chart-room-4); }
.rc-svg[data-rooms] [data-c="5"] { --rc: var(--chart-room-5); }
.rc-svg[data-rooms] [data-c="6"] { --rc: var(--chart-room-6); }
.rc-svg[data-rooms] [data-c="7"] { --rc: var(--chart-room-7); }
/* ⭐ The floor's THERMOSTAT (`climate_thermostat`): its measured line is the one prominent stroke on the chart, each setpoint a dashed step line (heat red-orange, cool blue), and a strip between the plots says when it ran. */
.rc-thermo { fill: none; stroke: var(--fgColor-default); stroke-width: 5.5; stroke-linejoin: round; stroke-linecap: round; }
.rc-setpoint { fill: none; stroke: var(--fgColor-attention); stroke-width: 3; stroke-dasharray: 10 6; stroke-linejoin: round; }
/* ⭐ Which setpoint (owner, 2026-10-21): heat red-orange, cool blue, the line and its words in the row alike. */
.rc-setpoint--heat { stroke: var(--chart-3); }
.rc-setpoint--cool { stroke: var(--chart-5); }
.rc-set--heat { fill: var(--chart-3); }
.rc-set--cool { fill: var(--chart-5); }
.rc-run-track { fill: var(--bgColor-layer1); }
.rc-run, .rc-swatch { stroke: none; }
.rc-run--cooling { fill: var(--chart-5); }
.rc-run--heating { fill: var(--fgColor-attention); }
.rc-run--aux { fill: var(--fgColor-danger); }
/* ⭐ The blower with nothing called (`Fan Running`; or the vibration sensor when the thermostat said nothing): the same strip, muted, so it reads as less than a call and never as one. */
.rc-run--fan { fill: var(--fgColor-muted); opacity: 0.45; }
.rc-run--idle { fill: var(--fgColor-muted); }
.rc-set { fill: var(--fgColor-attention); font-variant-numeric: tabular-nums; }
/* The outside: a thick light line over a soft halo (a stroke, so it can never end like a filled band and read as a clipped night). */
.rc-outside { fill: none; stroke: var(--chart-outside); stroke-width: 3; stroke-dasharray: 12 8; stroke-linecap: round; stroke-linejoin: round; opacity: 1; }
/* The halo's width and opacity are settings (`climate_chart_settings`), so they ride the element as attributes, not here. */
/* The off-scale outside: a dashed light rail on the plot's edge, an arrow at the extreme, a plate with the words. */
.rc-rail { stroke: var(--fgColor-default); stroke-width: 3.2; stroke-dasharray: 11 7; stroke-linecap: round; opacity: .9; }
.rc-rail-arrow { fill: var(--fgColor-default); }
.rc-plate { fill: var(--bgColor-default); opacity: .88; }
.rc-plate-text { fill: var(--fgColor-default); font-weight: 500; }
.rc-outside-halo { fill: none; stroke: var(--fgColor-default); stroke-linecap: round; stroke-linejoin: round; }
/* The standalone numbers: the same weight as the landing figures (`landing/figures.WEIGHT`), in the room's own color. */
.rc-num { fill: var(--rc, var(--fgColor-default)); font-weight: 300; font-variant-numeric: tabular-nums; }
.rc-deg { fill: var(--rc, var(--fgColor-muted)); font-weight: 300; }
.rc-quiet { fill: var(--fgColor-muted); }
.rc-name { fill: var(--rc, var(--fgColor-default)); font-weight: 500; }
.rc-track { fill: var(--bgColor-layer1); stroke: var(--borderColor-default); stroke-width: 1; }
.rc-fill { fill: var(--rc, var(--fgColor-muted)); opacity: .85; }
.rc-dot { fill: var(--fgColor-default); stroke: var(--bgColor-default); stroke-width: 3; }
.rc-range { fill: var(--fgColor-muted); font-variant-numeric: tabular-nums; }
/* The lanes. */
.rc-card { fill: var(--bgColor-layer1); }
.rc-band { fill: var(--bgColor-success-muted); }
.rc-edge { stroke: var(--borderColor-success); stroke-width: 1.5; stroke-dasharray: 8 6; }
.rc-edge-text { fill: var(--fgColor-success); }
.rc-lane-name { fill: var(--fgColor-default); font-weight: 500; }
.rc-lane .rc-num { fill: var(--fgColor-default); }
.rc-lane[data-verdict="in"] .rc-num, .rc-lane[data-verdict="in"] .rc-status { fill: var(--fgColor-success); }
.rc-lane[data-verdict="above"] .rc-num, .rc-lane[data-verdict="above"] .rc-status,
.rc-lane[data-verdict="below"] .rc-num, .rc-lane[data-verdict="below"] .rc-status { fill: var(--fgColor-attention); }
.rc-status { font-weight: 600; fill: var(--fgColor-muted); }
.rc-meta { fill: var(--fgColor-muted); font-variant-numeric: tabular-nums; }
.rc-line-in { stroke: var(--fgColor-default); stroke-width: 2.8; }
.rc-dot { fill: var(--fgColor-default); stroke: none; }
/* ⭐ The part of the line OUTSIDE the safe band is drawn again in attention color, clipped to the regions above and below it. */
.rc-line-out { stroke: var(--fgColor-attention); stroke-width: 3.6; }

/* --- motion rules, on /ui/reflexes ---------------------------------------
   ⚠️ A rule is a SENTENCE, so the list is a list of sentences rather than a
   table of fields: "camera:any turns on room hallway, off after 10 min of
   quiet unless mmwave:occupancy still sees somebody" reads; a row of ids does
   not. */
.rule-list { list-style: none; margin: 0 0 1rem; padding: 0; }
.rule {
  display: grid;
  /* ⚠️ `minmax(0, 1fr)` not `1fr` - a long sentence in a grid track with no
     min pushes the Delete button off the card. */
  grid-template-columns: minmax(0, 1fr) auto;
  gap: 0.25rem 0.75rem;
  align-items: start;
  padding: 0.6rem 0;
  border-bottom: 1px solid var(--borderColor-muted);
}
.rule-text { grid-column: 1; }
.rule-meta { grid-column: 1; }
.rule-delete { grid-column: 2; grid-row: 1 / span 2; align-self: center; }
/* ⚠️ A row's buttons share the right-hand column. Since Edit joined Delete
   (2026-09-25) a second button wrapped under the sentence, full width. */
.rule > button { grid-column: 2; }
/* ⛔ A stored rule that no longer validates is shown, not hidden - so it needs
   to LOOK wrong rather than merely say so. */
/* ⚠️ The token with NO literal fallback. `_tokens.css` is the one place a
   color is written and two ratchets hold that — a fallback hex is still a
   hex, and it was measured for contrast in neither theme. */
.rule-broken .rule-text { color: var(--fgColor-danger); }

.rule-form { display: grid; gap: 0.6rem; margin-top: 1rem; }
.rule-row {
  display: grid;
  grid-template-columns: 7rem minmax(0, 22rem) 1fr;
  gap: 0.5rem;
  align-items: center;
}
.rule-row label { font-weight: 500; }
.rule-row .meta { grid-column: 3; }
@media (max-width: 40rem) {
  /* ⚠️ The three-column form collapses rather than scrolling sideways: the
     page body must never scroll horizontally. */
  .rule-row { grid-template-columns: 1fr; }
  .rule-row .meta { grid-column: 1; }
}
/* --- the house heat map (2026-10-04) -----------------------------------------------------------------------------------------
   ⭐ One SVG, 1600 x 900 units, `meet`-scaled and fixed to the panel on a static display: text and shapes scale together. The room
   FILLS are data (`web/climate_heatmap` computes a hex per room), so this sheet holds only the quiet parts, all tokens. */
.hm-svg { display: block; width: 100%; max-width: 100%; }
body.is-static .hm-svg { position: fixed; inset: 0; height: 100%; }
.hm-svg text { font-family: inherit; }
/* ⛔ A colored room carries its fill as an ATTRIBUTE, and a CSS `fill` would beat it: `.hm-room` sets none, only the quiet rooms get one. */
.hm-room { stroke: none; }
.hm-quiet { fill: var(--bgColor-layer1); stroke: none; }
.hm-wall { stroke: var(--fgColor-muted); stroke-width: 2.4; stroke-linecap: square; }
.hm-solid { fill: var(--fgColor-muted); stroke: none; }
.hm-title { fill: var(--fgColor-default); font-weight: 500; }
/* The side list's story headings (Downstairs, Upstairs): quieter than a room's name, so they read as a grouping and not as one more row. */
.hm-floor { fill: var(--fgColor-muted); font-weight: 600; letter-spacing: .06em; text-transform: uppercase; }
.hm-label { fill: var(--fgColor-muted); }
/* A room's number sits on its colored fill: a dark outline of the page ground keeps it readable on every fill from blue to orange. */
.hm-num { fill: var(--fgColor-strong); font-weight: 600; stroke: var(--bgColor-default); stroke-width: 5; paint-order: stroke; font-variant-numeric: tabular-nums; }
.hm-name { fill: var(--fgColor-strong); font-weight: 500; stroke: var(--bgColor-default); stroke-width: 4; paint-order: stroke; }
.hm-tick { fill: var(--fgColor-muted); font-variant-numeric: tabular-nums; }
.hm-track { fill: var(--bgColor-layer1); stroke: var(--borderColor-default); stroke-width: 1; }
.hm-dot { fill: var(--fgColor-default); stroke: var(--bgColor-default); stroke-width: 3; }
/* ⭐ Where a placed reading comes from: light at 0.9 with the page ground as a 1.5 outline, so it reads on any heat fill and on a gray room. Tokens only. */
.hm-marker { fill: var(--fgColor-strong); fill-opacity: .9; stroke: var(--bgColor-default); stroke-width: 1.5; }
/* ⭐ A list row: the current value at the larger size, its 24 hour range (and the sensor count on the name) smaller and muted, on the row's own line. */
.hm-value { fill: var(--fgColor-default); font-variant-numeric: tabular-nums; }
.hm-range { fill: var(--fgColor-muted); font-weight: 400; font-variant-numeric: tabular-nums; }
.hm-bar .hm-name { stroke: none; fill: var(--fgColor-default); }
/* ⭐ A humidity dial: its colors (disc, track, arc, number) are attributes from the server; only the shape rules live here. */
.hm-ring { fill: none; stroke-linecap: round; }
.hm-dial-num { font-weight: 600; font-variant-numeric: tabular-nums; }
.hm-leader { stroke-linecap: round; }
/* ⭐ The display settings page (`display_settings.html`): a row per setting, the value and its default and bounds beside it. */
.ds-section { margin: 0 0 1.25rem; }
.ds-row { display: grid; grid-template-columns: minmax(9rem, 14rem) minmax(8rem, 12rem) 1fr auto; gap: .35rem .9rem; align-items: center; padding: .55rem 0; border-top: 1px solid var(--borderColor-muted); }
.ds-row .ds-why { grid-column: 1 / -1; margin: 0; }
.ds-band-row { grid-template-columns: minmax(9rem, 14rem) auto auto 1fr auto; }
.ds-edge { white-space: nowrap; }
.ds-name { font-weight: 600; }
.ds-input { display: inline-flex; align-items: center; gap: .4rem; }
.ds-input input, .ds-edge input { width: 7rem; }
/* ⚠️ A checkbox is not a number box: the 7rem above stretched each of the working day's seven boxes, and the row ran past its card. A set of boxes (`days`, `checks`) wraps inside its column. */
.ds-input input[type="checkbox"] { width: auto; }
.ds-input[data-days], .ds-input[data-checks] { flex-wrap: wrap; gap: .25rem .75rem; }
.ds-edge { display: inline-flex; align-items: center; gap: .4rem; color: var(--fgColor-muted); }
.ds-name-input { width: 14rem; max-width: 100%; }
.ds-unit { color: var(--fgColor-muted); }
.ds-default { color: var(--fgColor-muted); font-size: .85rem; }
.ds-buttons { display: inline-flex; gap: .5rem; justify-content: flex-end; }
.ds-save { margin: .75rem 0 0; display: flex; gap: .5rem; flex-wrap: wrap; }
.ds-zone { margin: .75rem 0; padding: .5rem .75rem; border: 1px solid var(--borderColor-muted); border-radius: .5rem; }
.ds-floor { display: flex; flex-wrap: wrap; gap: .25rem .9rem; align-items: center; margin: .3rem 0; }
.ds-place { white-space: nowrap; }
.ds-msg { min-height: 1.4em; margin: .6rem 0 0; }
@media (max-width: 44rem) { .ds-row, .ds-band-row { grid-template-columns: 1fr; } .ds-buttons { justify-content: flex-start; } }
/* ⭐ **The scroll wrapper for a table wider than a phone** (plan `page-map`, `ad-slo`, 2026-10-25). `speech.html` wrapped its table in `.table-scroll` and the class was styled only inside `.md` documents (`_doc_style.html`), so on every other page it was a bare div and the table widened the PAGE at 390 px. A wrapper scrolls itself; the page does not. */
.table-scroll { overflow-x: auto; max-width: 100%; }
/* ⭐ The SLO page (`slo.html`, BACKLOG 2.36): a 28-cell daily strip per objective, a budget meter and a diagnostics list. Tokens only. */
.slo-row { margin: 0 0 1rem; }
.slo-tier { text-transform: uppercase; letter-spacing: .04em; }
.slo-tier[data-tier="shadow"] { background: var(--bgColor-attention-muted); color: var(--fgColor-attention); }
.slo-tier[data-tier="armed"] { background: var(--bgColor-danger-muted); color: var(--fgColor-danger); }
.slo-budget { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem; }
.slo-meter { width: 9rem; height: .8rem; vertical-align: middle; }
.slo-strip { display: grid; grid-template-columns: repeat(28, minmax(0, 1fr)); gap: 2px; margin: .5rem 0 .25rem; max-width: 36rem; }
.slo-cell { display: block; height: 1.1rem; border-radius: 2px; background: var(--bgColor-muted); border: 1px solid var(--borderColor-muted); }
.slo-cell[data-state="ok"] { background: var(--fgColor-success); border-color: var(--fgColor-success); }
.slo-cell[data-state="bad"] { background: var(--fgColor-danger); border-color: var(--fgColor-danger); }
.slo-cell[data-state="error"] { background: repeating-linear-gradient(45deg, var(--bgColor-muted) 0 3px, var(--borderColor-default) 3px 5px); }
.slo-legend { display: flex; flex-wrap: wrap; align-items: center; gap: .25rem .6rem; }
.slo-key { display: inline-block; width: .8rem; height: .8rem; vertical-align: middle; }
.slo-diag { display: grid; grid-template-columns: max-content 1fr; gap: .15rem .9rem; margin: .5rem 0; font-size: .85rem; }
.slo-diag dt { color: var(--fgColor-muted); }
.slo-diag dd { margin: 0; font-variant-numeric: tabular-nums; }
.slo-services { list-style: none; margin: .25rem 0 0; padding: 0; max-width: 24rem; }
.slo-services li { display: flex; justify-content: space-between; gap: 1rem; padding: .15rem 0; border-top: 1px solid var(--borderColor-muted); }
.slo-table { width: 100%; border-collapse: collapse; font-size: .85rem; margin: .5rem 0 0; }
.slo-table th { text-align: left; color: var(--fgColor-muted); font-weight: 500; padding: .25rem .6rem .25rem 0; }
.slo-table td { padding: .25rem .6rem .25rem 0; border-top: 1px solid var(--borderColor-muted); }
/* ---- the light rows: ONE aligned grid on the Lights, Rooms and Zones pages (owner, 2026-10-04: "let's try one column. the distinction between light rows should be clear") ----
   [dot] | name + rename + fault line | brightness | % | switch | rest. Every row is its own band; the second line (warm/cool, color) is a subgrid of the same columns, so a
   slider sits under the slider above it. Placement is by CLASS and CHILD selector, never by touching a control's own markup: every id, label and data-action is unchanged. */
.mlist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .5rem; }
.mrow {
  display: grid; grid-template-columns: 2.2rem minmax(11rem, 15rem) minmax(8rem, 13rem) 3.6rem auto 1fr;
  align-items: center; column-gap: .9rem; row-gap: .2rem; padding: .55rem .9rem;
  background: var(--bgColor-default); border: 1px solid var(--borderColor-default); border-radius: 10px;
}
.mrow > .mdot { grid-column: 1; width: 2.2rem; height: 2.2rem; }
.mrow > .mname { grid-column: 2; }
.mrow > input.brightness { grid-column: 3; width: 100%; }
.mrow > .brightness-value { grid-column: 4; text-align: right; }
.mrow > .switch { grid-column: 5; }
.mrow > .mbadge { grid-column: 6; justify-self: start; }
.mdot[data-state="off"] { color: var(--borderColor-emphasis); }
.mdot[data-state="unreachable"] { color: var(--fgColor-attention); }
.mname { display: flex; flex-direction: column; gap: .15rem; min-width: 0; }
.mtitle { display: flex; align-items: center; gap: .4rem; min-width: 0; flex-wrap: wrap; }
/* The fault (or "Connected") sits UNDER the name: it used to be drawn over it. */
.mname .conn-inline { font-size: .78rem; white-space: normal; }
.mfault { font-size: .78rem; color: var(--fgColor-danger); }
.mmembers { display: flex; flex-wrap: wrap; gap: .25rem .8rem; font-size: .8rem; color: var(--fgColor-muted); }
.mmember { display: inline-flex; align-items: center; gap: .3rem; }
.mmember > .mdot { width: 1rem; height: 1rem; }
.msecond { grid-column: 2 / 7; display: grid; grid-template-columns: subgrid; align-items: center; row-gap: .2rem; }
.msecond > .mlabel:first-child { justify-self: end; }
.msecond > .mlabel { color: var(--fgColor-muted); font-size: .85rem; }
/* The second line is a SUBGRID of columns 2-6, so its own columns are 1-5: label | slider | kelvin | switch column | rest. */
.msecond > input.ct-slider { grid-column: 2; width: 100%; }
/* A gap after the kelvin, so "2750K" and the Color label that follows read as two things. */
.msecond > .ct-value { grid-column: 3; text-align: right; margin-right: .9rem; }
.mextra { grid-column: 4 / span 2; display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
.msecond > .mreach { grid-column: 4; white-space: nowrap; }
.msecond--reach > .mextra { grid-column: 5; }
.msecond > .color-label:first-child { justify-self: end; color: var(--fgColor-muted); font-size: .85rem; }
.mextra--first { grid-column: 1 / span 4; }
.mextra > .mlabel { color: var(--fgColor-muted); font-size: .85rem; }
.mreach { color: var(--fgColor-muted); font-size: .78rem; }
.mrow--area { background: var(--bgColor-inset); }
.mrow--area .row-name { font-weight: 600; }
.area-list > .area { padding: 0; }
.area-list > .area > .area-members { margin: .35rem 0 0 1.6rem; padding-left: .8rem; }
@media (max-width: 44rem) {
  .mrow { grid-template-columns: 2.2rem minmax(0, 1fr) 3.2rem auto; }
  .mrow > .mname { grid-column: 2 / 5; }
  .mrow > input.brightness { grid-column: 1 / 3; }
  .mrow > .brightness-value { grid-column: 3; }
  .mrow > .switch { grid-column: 4; }
  .mrow > .mbadge { grid-column: 2 / 5; }
  .msecond { grid-column: 1 / 5; grid-template-columns: subgrid; }
  .msecond > .mlabel:first-child, .msecond > .color-label:first-child { justify-self: start; grid-column: 1 / 5; }
  .msecond > input.ct-slider { grid-column: 1 / 3; }
  .msecond > .ct-value { grid-column: 3; }
  .msecond > .mreach { grid-column: 4; }
  .mextra, .mextra--first, .msecond--reach > .mextra { grid-column: 1 / 5; }
}
/* ⭐ The food log (`/ui/food`, BACKLOG 9.73): built for a tablet and a phone at an open fridge, so every control is a big touch target (3rem) and nothing needs a horizontal scroll.
   ⚠️ Wrapping flex everywhere, never a fixed width: the location chips and the row actions are the widest things on the page and must break onto a second line at 390px. */
.food-form { display: flex; flex-direction: column; gap: .6rem; }
.food-form .field-pair { display: flex; flex-direction: column; gap: .25rem; width: 100%; }
.food-form input, .food-edit-fields input, .food-edit-fields select { width: 100%; box-sizing: border-box; font-size: 1rem; min-height: 2.75rem; }
.food-where { display: flex; flex-wrap: wrap; gap: .5rem; border: 0; margin: 0; padding: 0; min-width: 0; }
.food-where legend { padding: 0; margin-bottom: .3rem; color: var(--fgColor-muted); }
.food-pick, .food-act, .food-step, .food-save { min-height: 3rem; padding: .5rem 1rem; font-size: 1rem; }
.food-pick[aria-pressed="true"], .food-act[aria-pressed="true"] { background: var(--bgColor-accent-emphasis); color: var(--fgColor-onEmphasis); }
.food-when-row { display: flex; align-items: center; gap: .6rem; }
.food-step { width: 3rem; padding: 0; font-size: 1.4rem; flex: 0 0 auto; }
.food-chip { flex: 1 1 auto; min-width: 0; text-align: center; padding: .6rem .5rem; border-radius: 6px; background: var(--bgColor-field); overflow-wrap: anywhere; }
.food-toggles, .food-chips, .food-filter, .food-actions, .food-still { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; }
.food-save { flex: 1 1 10rem; font-weight: 600; }
.food-form .is-hidden { display: none; }
.food-filter { margin: .4rem 0; }
.food-when { font-weight: 600; white-space: nowrap; }
.food-when[data-tone="danger"], #food-summary [data-tone="danger"] { color: var(--fgColor-danger); }
.food-when[data-tone="warn"], #food-summary [data-tone="warn"] { color: var(--fgColor-attention); }
#food-list > li[data-tone] { padding-left: .6rem; border-left: 4px solid var(--borderColor-emphasis); }
#food-list > li[data-tone="danger"] { border-left-color: var(--fgColor-danger); }
#food-list > li[data-tone="warn"] { border-left-color: var(--fgColor-attention); }
#food-list > li.is-hidden { display: none; }
/* ⭐ A row whose notes say `hi` (owner, 2026-10-17): a tinted ground and a thick marker on the left, ACCENT tokens only (the tone border above is danger and attention, so the two never read as one). The note keeps its word. */
#food-list > li.is-high { background: var(--bgColor-accent-muted); box-shadow: inset .5rem 0 0 var(--fgColor-accent); margin-inline: -.75rem; padding-inline: .75rem; }
/* ⭐ A row is ONLY what scanning needs (owner, 2026-10-10: quick to scroll through, and the log will hold about three times what it does): the name, the place, how long, Eaten and Tossed. The date, the "still
   there" pushes and the fields are under Edit. One grid: a phone is two lines (name and how long; place, Eaten, Tossed, Edit), 48rem and up is ONE line. An open Edit spans the row beneath.
   ⚠️ The row's buttons are 2.75rem (44px, the WCAG target size) rather than the form's 3rem: a third of the height, on the control used most. */
#food-list > li { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: .2rem .5rem; align-items: center; padding: .3rem 0; }
#food-list .row-name { grid-column: 1 / 3; grid-row: 1; min-width: 0; overflow-wrap: anywhere; }
#food-list .food-when { grid-column: 3; grid-row: 1; justify-self: end; }
#food-list .food-tags { grid-column: 1; grid-row: 2; min-width: 0; margin: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
#food-list .food-tags > .badge { margin-left: 0; }
#food-list .food-actions { grid-column: 2; grid-row: 2; flex-wrap: nowrap; }
#food-list .food-edit { grid-column: 3; grid-row: 2; }
#food-list .food-edit[open] { grid-column: 1 / -1; grid-row: 3; }
/* ⭐ Save and Cancel side by side (owner, 2026-10-18), wrapping on a narrow phone. */
.food-edit-buttons { display: flex; flex-wrap: wrap; gap: .5rem; }
#food-list > li > [role="status"] { grid-column: 1 / -1; }
#food-list > li > [role="status"]:empty { display: none; }
#food-list .food-actions .food-act, #food-list .food-edit > summary { min-height: 2.75rem; padding: .35rem .6rem; }
#food-list .food-actions { gap: .4rem; }
.food-edit summary { cursor: pointer; list-style: none; display: flex; align-items: center; color: var(--fgColor-muted); border-radius: 6px; }
.food-edit summary::-webkit-details-marker { display: none; }
.food-edit summary::after { content: "\25BE"; margin-left: .3rem; font-size: .8em; }
.food-edit[open] > summary::after { content: "\25B4"; }
.food-edit[open] > summary { color: var(--fgColor-default); }
@media (width >= 48rem) {
  #food-list > li { grid-template-columns: minmax(0, 1fr) auto auto auto auto; column-gap: .7rem; }
  #food-list .row-name { grid-column: 1; grid-row: 1; }
  #food-list .food-tags { grid-column: 2; grid-row: 1; }
  #food-list .food-when { grid-column: 3; grid-row: 1; }
  #food-list .food-actions { grid-column: 4; grid-row: 1; }
  #food-list .food-edit { grid-column: 5; grid-row: 1; }
  #food-list .food-edit[open] { grid-column: 1 / -1; grid-row: 2; }
}
.food-edit-fields { display: flex; flex-direction: column; gap: .7rem; padding: .5rem 0; }
.food-edit-fields .field-pair { display: flex; flex-direction: column; gap: .25rem; }
/* ⭐ The add form on a wide screen: Item and Notes side by side, the date stepper beside the Printed/Leftovers toggles, everything else full width; a phone keeps the one column. */
@media (min-width: 46rem) {
  .food-form { display: grid; grid-template-columns: 1fr 1fr; gap: .6rem 1rem; align-items: end; }
  .food-form > * { grid-column: 1 / -1; }
  .food-form > label[for="food-name"] { grid-column: 1; grid-row: 1; }
  .food-form > label[for="food-notes"] { grid-column: 2; grid-row: 1; }
  .food-form > .food-when-row { grid-column: 1; }
  .food-form > .food-toggles { grid-column: 2; }
}
/* ⭐ The thermostats (`/ui/thermostats`, BACKLOG 2.4): read-only, phone and tablet first. Wrapping flex and an auto-fit grid, never a fixed width, so the house strip and the two cards stack at 390px
   and sit side by side on a tablet. ⚠️ Only theme tokens: the color-literal ratchet counts anything else. */
.thermo-strip { display: flex; flex-wrap: wrap; gap: .8rem 1.6rem; align-items: flex-end; }
.thermo-fact { display: flex; flex-direction: column; gap: .1rem; min-width: 0; }
.thermo-outdoor { font-size: 1.6rem; font-weight: 600; }
.thermo-login { font-weight: 600; }
.thermo-login[data-tone="ok"] { color: var(--fgColor-success); }
.thermo-login[data-tone="warn"] { color: var(--fgColor-attention); }
.thermo-login[data-tone="danger"] { color: var(--fgColor-danger); }
.thermo-lock { margin-top: .8rem; padding: .6rem .8rem; border-left: 4px solid var(--borderColor-danger); background: var(--bgColor-danger-muted); border-radius: 6px; }
.thermo-lock p { margin: .2rem 0; overflow-wrap: anywhere; }
.thermo-zones { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 17rem), 1fr)); gap: 1rem; }
.thermo-zone { margin: 0; min-width: 0; }
.thermo-zone[data-offline="true"] { border-left: 4px solid var(--borderColor-danger); }
.thermo-zone h2 { margin: 0; }
.thermo-temp { font-size: 3.2rem; font-weight: 600; line-height: 1.1; margin: .4rem 0 .2rem; }
.thermo-unit { font-size: 1.2rem; font-weight: 400; color: var(--fgColor-muted); }
.thermo-doing { margin: 0 0 .6rem; }
.thermo-doing[data-doing="cool"] strong { color: var(--fgColor-accent); }
.thermo-doing[data-doing="heat"] strong, .thermo-doing[data-doing="warn"] strong { color: var(--fgColor-attention); }
.thermo-facts { display: flex; flex-wrap: wrap; gap: .5rem 1.4rem; margin: 0; }
.thermo-facts > div { display: flex; flex-direction: column; gap: .1rem; }
.thermo-facts dt { color: var(--fgColor-muted); font-size: .85rem; }
.thermo-facts dd { margin: 0; }
.thermo-facts dd[data-tone="warn"] { color: var(--fgColor-attention); }
/* ⭐ The thermostats' day section (phase 2): the picker is links, the strip is inline SVG scaled to its card (a user-unit coordinate system, never a pixel width), and every color is a theme token so both
   themes work. The bands match the phase-1 tones: cool is the accent, heat and aux are the attention and danger tones. */
.thermo-picker { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: .5rem 1rem; }
.thermo-step { min-height: 2.75rem; display: inline-flex; align-items: center; padding: 0 .4rem; }
.thermo-step-off { opacity: .45; }
.thermo-date { text-align: center; flex: 1 1 auto; }
.thermo-legend { display: flex; flex-wrap: wrap; gap: .3rem 1rem; margin: .6rem 0 0; }
.thermo-swatch { display: inline-block; width: .8rem; height: .8rem; border-radius: 2px; vertical-align: -1px; }
.thermo-swatch[data-kind="cooling"] { background: var(--fgColor-accent); }
.thermo-swatch[data-kind="heating"] { background: var(--fgColor-attention); }
.thermo-swatch[data-kind="aux"] { background: var(--fgColor-danger); }
.thermo-swatch[data-kind="tick"] { background: var(--fgColor-default); width: .2rem; }
.thermo-day-zone { margin-top: .9rem; padding-top: .7rem; border-top: 1px solid var(--borderColor-muted); }
.thermo-ran { margin: 0 0 .3rem; font-weight: 600; }
.thermo-svg { display: block; width: 100%; height: 3.4rem; overflow: visible; }
.thermo-ground { fill: var(--bgColor-inset); stroke: var(--borderColor-muted); stroke-width: 1; vector-effect: non-scaling-stroke; }
.thermo-grid { stroke: var(--borderColor-muted); stroke-width: 1; vector-effect: non-scaling-stroke; }
.thermo-band[data-kind="cooling"] { fill: var(--fgColor-accent); }
.thermo-band[data-kind="heating"] { fill: var(--fgColor-attention); }
.thermo-band[data-kind="aux"] { fill: var(--fgColor-danger); }
.thermo-gap { stroke: var(--borderColor-muted); stroke-width: 1; vector-effect: non-scaling-stroke; }
.thermo-runtime { margin-top: .9rem; padding-top: .7rem; border-top: 1px solid var(--borderColor-muted); }
.thermo-svg-months { height: 6.2rem; }
.thermo-bar[data-kind="cooling"] { fill: var(--fgColor-accent); }
.thermo-bar[data-kind="heating"] { fill: var(--fgColor-attention); }
.thermo-bar[data-kind="aux"] { fill: var(--fgColor-danger); }
.thermo-bar[data-stage="2"] { fill-opacity: .7; }
.thermo-bar[data-stage="3"] { fill-opacity: .45; }
.thermo-facts-line { margin: .5rem 0 0; }
.thermo-hatch-line { stroke: var(--fgColor-muted); stroke-width: 1.5; }
.thermo-swatch[data-kind="gap"] { background: repeating-linear-gradient(45deg, var(--fgColor-muted) 0 2px, transparent 2px 5px); border: 1px solid var(--borderColor-muted); }
.thermo-tick { stroke: var(--fgColor-default); stroke-width: 2; vector-effect: non-scaling-stroke; }
.thermo-now { stroke: var(--fgColor-danger); stroke-width: 2; stroke-dasharray: 3 2; vector-effect: non-scaling-stroke; }
.thermo-axis { fill: var(--fgColor-muted); font-size: 9px; }
.thermo-log { margin-top: .3rem; }
.thermo-log summary { cursor: pointer; padding: .6rem 0; color: var(--fgColor-muted); }
.thermo-log-list { margin: 0; padding-left: 1.1rem; }

/* --- the traffic-map slide (BACKLOG 2.43, PR 1; the mockup's layout) ------------------------------------------
   ⭐ A TV picture drawn in the mockup's 1920 x 1080 coordinates and scaled to fit (`.tm-stage`). ⛔ **Every color is the rotation's own token** (owner, 2026-10-12: *"to match the style of the rest of the rotation"*; home3): cards are `--bgColor-layer1` with the
   `--borderColor-muted` rule, text `--fgColor-default` and `--fgColor-muted`, the camera captions and leader lines the page's inset. The page's `<html data-theme>` is the slide's `map_theme` (`_theme`'s `page_theme`), so the tokens' `light-dark()` pairs flip with it:
   no second palette, no color literal. The one color that is not a token is `--tm-water`, MAP DATA set from `core/traffic_map.WATER` (the water behind the tiles, matching Google's style). */
.tm-frame { position: relative; width: 100%; height: calc(1080px * var(--tm-scale, 1)); overflow: hidden; background: var(--tm-water, var(--bgColor-inset)); }
.tm-frame.tm-fill { position: fixed; inset: 0; height: auto; }
.tm-stage { position: absolute; left: var(--tm-ox, 0px); top: var(--tm-oy, 0px); width: 1920px; height: 1080px; transform-origin: 0 0; transform: scale(var(--tm-scale, 1)); }
.tm-layer { position: absolute; left: 0; top: 0; width: 1920px; height: 1080px; }
/* ⛔ No width or height here: a tile is drawn at its MAP's size, the img's own width/height (512 regional, 256 local). A fixed 512 here drew the local map's tiles at twice their size, spilling over each other (2026-10-13). */
.tm-tile { position: absolute; display: block; }
.tm-cam { position: absolute; background: var(--bgColor-inset); }
.tm-cam-img { display: block; object-fit: cover; }
.tm-nopic { display: flex; align-items: center; justify-content: center; color: var(--fgColor-muted); font-size: 18px; }
.tm-nopic[hidden] { display: none; }
.tm-capt .lead { stroke: var(--bgColor-inset); stroke-width: 3; opacity: .8; fill: none; }
.tm-capt .pin { fill: var(--bgColor-inset); stroke: var(--fgColor-onEmphasis); stroke-width: 2.5; }
/* ⭐ A camera whose real position is OFF the frame (the local map's SH-130 @ SH-45 N): the pin sits just inside the edge, hollow and dashed. */
.tm-capt .pin.off { fill: none; stroke-dasharray: 4 3; }
.tm-capt .cap { fill: var(--bgColor-inset); }
.tm-capt .k { fill: var(--fgColor-default); font-size: 18px; font-weight: 600; }
.tm-capt .v { fill: var(--fgColor-muted); font-size: 15px; font-weight: 600; }
/* ⛔ The CHILD rectangle only: Google's logo is drawn with paths and ONE `<rect>`, which is the letter "l", so a `.tm-attrib rect` rule filled it with the card's dark color and the logo read "Goog e Maps" (found with the animation harness, 2026-10-13). */
.tm-attrib > rect { fill: var(--bgColor-layer1); fill-opacity: .9; }
.tm-attrib text { fill: var(--fgColor-default); font-size: 13px; }
.tm-logo svg { overflow: visible; }
.tm-nightnote rect { fill: var(--bgColor-layer1); fill-opacity: .9; stroke: var(--borderColor-muted); stroke-width: 1.5; }
.tm-nightnote text { fill: var(--fgColor-default); font-size: 15px; font-weight: 600; }
/* ⭐ PR 2: the wind, the planes and the satellite. The wind arrows are the rotation's own blue (`--fgColor-blocked`); a plane's color and its route are the tar1090 altitude scale (map data, computed by the page's script from the altitude, never a literal);
   everything else is a token. The script's elements carry data attributes, not classes, so a rule here names them by attribute. */
.tm-wind-arrow .shaft { stroke: var(--fgColor-blocked); stroke-width: 4; stroke-linecap: round; fill: none; }
.tm-wind-arrow .head { fill: var(--fgColor-blocked); }
.tm-wind-calm circle { fill: none; stroke: var(--fgColor-blocked); stroke-width: 2.5; }
.tm-wind-arrow .wv, .tm-wind-calm .wv { fill: var(--fgColor-default); font-size: 15px; font-weight: 600; paint-order: stroke; stroke: var(--bgColor-default); stroke-width: 4; }
.tm-windchip rect { fill: var(--bgColor-layer1); stroke: var(--fgColor-blocked); stroke-width: 2; }
.tm-windchip .shaft { stroke: var(--fgColor-blocked); stroke-width: 3.5; stroke-linecap: round; fill: none; }
.tm-windchip .head { fill: var(--fgColor-blocked); }
.tm-windchip .k { fill: var(--fgColor-default); font-size: 15px; font-weight: 600; }
.tm-windchip .v { fill: var(--fgColor-muted); font-size: 13px; }
/* ⭐ PR 3: the left column's cards, the creek badges and the hazard edge chips. Every color is a token (the tone names the hazard: fire attention, creeks blue, traffic success, quakes accent); the traffic bar's four colors are `--tm-tr-*`, MAP DATA set from `core/traffic_map.TRAFFIC_BAR_COLORS`. */
/* ⛔ Opaque, unlike the chips: the column covers the map, so a tenth of it showing through is only noise, and on the TV (2026-10-13) a wind station's "2·g7" read through the TRAFFIC card. */
.tm-card rect:first-child { fill: var(--bgColor-layer1); stroke-width: 2; }
.tm-card .h { font-size: 14px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; }
.tm-card .s { fill: var(--fgColor-muted); font-size: 12px; }
.tm-card .k { fill: var(--fgColor-default); font-size: 15px; font-weight: 600; }
.tm-card .v { fill: var(--fgColor-muted); font-size: 13px; }
.tm-card .v.rise { fill: var(--fgColor-attention); font-weight: 600; }
.tm-card .n { fill: var(--fgColor-strong); font-size: 26px; font-weight: 700; }
.tm-card .u { fill: var(--fgColor-muted); font-size: 12px; }
.tm-card [hidden] { display: none; }
.tone-fire rect:first-child { stroke: var(--fgColor-attention); }
.tone-fire .h { fill: var(--fgColor-attention); }
.tone-creek rect:first-child { stroke: var(--fgColor-blocked); }
.tone-creek .h { fill: var(--fgColor-blocked); }
.tone-traffic rect:first-child { stroke: var(--fgColor-success); }
.tone-traffic .h { fill: var(--fgColor-success); }
.tone-quake rect:first-child { stroke: var(--fgColor-accent); }
.tone-quake .h { fill: var(--fgColor-accent); }
.tone-legend rect:first-child { stroke: var(--borderColor-muted); }
.tone-legend .h { fill: var(--fgColor-heading); }
.tm-card .bar-track { fill: var(--bgColor-inset); }
.tm-card .bar-green { fill: var(--tm-tr-green); }
.tm-card .bar-orange { fill: var(--tm-tr-orange); }
.tm-card .bar-red { fill: var(--tm-tr-red); }
.tm-card .bar-dark-red { fill: var(--tm-tr-dark-red); }
.tm-card .legend-route { stroke: var(--fgColor-muted); stroke-width: 3.5; stroke-dasharray: 12 9; fill: none; }
.tm-card .legend-plane { fill: var(--fgColor-muted); stroke: var(--bgColor-inset); stroke-width: 1; transform: scale(1.1); transform-box: fill-box; transform-origin: center; }
.tm-card .legend-sat .body { fill: var(--fgColor-heading); }
.tm-card .legend-sat .panel { fill: var(--fgColor-blocked); }
.tm-badge rect { fill: var(--bgColor-layer1); fill-opacity: .92; stroke: var(--fgColor-blocked); stroke-width: 2; }
.tm-badge .lead { stroke: var(--fgColor-blocked); stroke-width: 2.5; fill: none; opacity: .85; }
.tm-badge .dot { fill: var(--bgColor-inset); stroke: var(--fgColor-blocked); stroke-width: 2.5; }
.tm-badge .k { fill: var(--fgColor-default); font-size: 15px; font-weight: 600; }
.tm-badge .v { fill: var(--fgColor-muted); font-size: 13px; }
.tm-badge.rise rect, .tm-badge.rise .lead, .tm-badge.rise .dot { stroke: var(--fgColor-attention); }
.tm-badge.rise .v { fill: var(--fgColor-attention); font-weight: 600; }
.tm-hazchip rect { fill: var(--bgColor-layer1); fill-opacity: .9; stroke-width: 2.5; }
.tm-hazchip.dashed rect { stroke-dasharray: 7 5; }
.tm-hazchip .k { fill: var(--fgColor-default); font-size: 14px; font-weight: 600; }
.tm-hazchip .v { fill: var(--fgColor-muted); font-size: 13px; }
.tm-hazchip .icon path { fill: none; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }
.tm-hazchip.tone-fire .icon path { fill: var(--fgColor-attention); stroke: none; }
.tone-quake.tm-hazchip rect { stroke: var(--fgColor-accent); }
.tone-quake.tm-hazchip .arrow, .tone-quake.tm-hazchip .icon path { fill: var(--fgColor-accent); }
.tone-quake.tm-hazchip .icon path { fill: none; stroke: var(--fgColor-accent); }
.tone-fire.tm-hazchip rect { stroke: var(--fgColor-attention); }
.tone-fire.tm-hazchip .arrow { fill: var(--fgColor-attention); }
.tone-creek.tm-hazchip rect { stroke: var(--fgColor-blocked); }
.tone-creek.tm-hazchip .arrow { fill: var(--fgColor-blocked); }
.tone-creek.tm-hazchip .icon path { stroke: var(--fgColor-blocked); }
.tm-hazchip.rise rect { stroke: var(--fgColor-attention); }
.tm-hazchip.rise .arrow { fill: var(--fgColor-attention); }
.tm-hazchip.rise .icon path { stroke: var(--fgColor-attention); }
.tm-hazchip.rise .v { fill: var(--fgColor-attention); font-weight: 600; }
[data-slot="routes"] [data-route], [data-slot="routes"] [data-trail] { fill: none; stroke-width: 3.5; stroke-linecap: butt; stroke-dasharray: 12 9; opacity: .85; }
[data-plane] [data-icon] { stroke: var(--bgColor-inset); stroke-width: 1.3; vector-effect: non-scaling-stroke; }
[data-plane] [data-box] rect { fill: var(--bgColor-layer1); fill-opacity: .9; stroke-width: 2.5; }
[data-plane] [data-cs] { fill: var(--fgColor-strong); font-size: 17px; font-weight: 600; }
[data-plane] [data-sp] { fill: var(--fgColor-muted); font-size: 14px; }
[data-sat] [data-under] { fill: none; stroke: var(--bgColor-inset); stroke-width: 12; stroke-dasharray: 4 14; stroke-linecap: round; opacity: .9; }
[data-sat] [data-track] { fill: none; stroke: var(--fgColor-heading); stroke-width: 7; stroke-dasharray: 4 14; stroke-linecap: round; }
[data-sat] [data-glow] { fill: var(--fgColor-onEmphasis); opacity: .35; }
[data-sat] [data-core] { fill: var(--fgColor-onEmphasis); stroke: var(--borderColor-emphasis); stroke-width: 1.5; }
/* ⭐ The satellite's edge chip: the SAME dark card as the other chips (owner, 2026-10-12), with silver (`--fgColor-heading`) kept for its border, arrow and icon; the panels are the rotation's blue. */
[data-edgechip] rect { fill: var(--bgColor-layer1); fill-opacity: .9; stroke: var(--fgColor-heading); stroke-width: 2.5; }
[data-edgechip] [data-arrow] { fill: var(--fgColor-heading); }
[data-edgechip] [data-body] { fill: var(--fgColor-heading); stroke: none; }
[data-edgechip] [data-panel] { fill: var(--fgColor-blocked); stroke: none; }
[data-edgechip] [data-k] { fill: var(--fgColor-default); font-size: 14px; font-weight: 600; }
[data-edgechip] [data-v] { fill: var(--fgColor-muted); font-size: 13px; }
/* The food import's review rows (`/ui/food/import`): a tick, the name, the place and the date on one line when there is room, the estimate and the printed line under them. */
.food-import { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .2rem; }
.food-import-row { display: grid; grid-template-columns: auto minmax(0, 1fr) minmax(0, 10rem) minmax(0, 9rem); gap: .3rem .5rem; align-items: center; padding: .4rem 0; border-bottom: 1px solid var(--borderColor-muted); }
.food-import-row > input[type="checkbox"] { width: 1.5rem; height: 1.5rem; }
.food-import-row > input[type="text"], .food-import-row > select, .food-import-row > input[type="date"] { min-width: 0; box-sizing: border-box; font-size: 1rem; min-height: 2.75rem; }
.food-import-row > .meta { grid-column: 2 / -1; overflow-wrap: anywhere; }
.food-import-row > .food-act { grid-column: 2 / -1; justify-self: start; }
.food-import-row.is-added { opacity: .6; }
@media (max-width: 40rem) { .food-import-row { grid-template-columns: auto minmax(0, 1fr); } .food-import-row > select, .food-import-row > input[type="date"] { grid-column: 2; } }