/* FusionCore design tokens - per FDB section 9.12 (Tesla-app-inspired).
   Dark is the primary theme, not a variant. One accent colour. */

/* The FUSION CORE wordmark font, self-hosted so it renders on every
   device (phone, other computers) offline. */
@font-face {
  font-family: "Eurostile MN Extended Bold";
  src: url("fonts/eurostile-mn-extended-bold.ttf") format("truetype");
  font-weight: 700;
  font-style: normal;
  font-display: swap;
}

/* The sensor readout font (GK, 21 Jul 2026, second cut - B612 Mono read as
   "nasty" at display size): Inter, light weight, TABULAR figures. The clean
   geometric face of the app's Tesla-inspired look - airy rather than chunky
   at 130px, and tabular-nums keeps the number from shifting as digits change,
   which is what the monospace was really for. Variable font, self-hosted
   (OFL licence beside the file) so it renders offline on every device. */
@font-face {
  font-family: "Inter";
  src: url("fonts/InterVariable.ttf") format("truetype");
  font-weight: 100 900;
  font-style: normal;
  font-display: swap;
}

/* Roboto for the sensor readout (GK, 22 Jul 2026 - third cut, Bold this
   time). Variable font, self-hosted like Inter (OFL licence beside the
   file) so the readout renders offline on every device. Roboto's digits
   are tabular by default, so the number never shifts as it changes. */
@font-face {
  font-family: "Roboto";
  src: url("fonts/RobotoVariable.ttf") format("truetype");
  font-weight: 100 900;
  font-style: normal;
  font-display: swap;
}

:root {
  --bg: #0b0c0e;             /* near-black app background */
  --surface: #16181c;        /* card surface */
  --surface-raised: #1c1f24;
  /* One step lighter again: the CHOSEN button of a pair. Above the
     hover tone on purpose, so a hovered button never looks selected. */
  --surface-chosen: #333941;
  --border: #26292f;
  /* The chosen button's edge - one step up from --border so the lift
     has an outline as well as a face. ⚠ It was referenced before it
     existed, which CSS answers by silently keeping the old value:
     the same invisible no-op as a class with no rule behind it. */
  --border-strong: #3f454e;
  --text: #f2f3f5;
  /* Body copy, tuned by GK's eye live (23 Jul 2026:
     d9dce1 -> ccc -> ddd -> fff -> aaa). Headings hold --text. */
  --text-body: #aaa;
  --text-secondary: #9aa0a8;
  --text-dim: #5c626b;
  --accent: #2e6bff;         /* single electric blue */
  /* The connection diagram's own line tone - the node boxes' borders and
     the wires between them, one value so they are drawn alike. */
  --chain-line: #454c57;
  --accent-glow: rgba(46, 107, 255, 0.35);
  /* One palette, one place (9 Aug 2026 consistency pass): the readout
     orange, the error red, the recording red and the login panel were
     literals scattered through this file - and the two newest pages had
     each invented their own error red (#ff6b6b), which is exactly the
     drift a token exists to prevent. rgba() tints of these colours stay
     literal (var() cannot carry an alpha). */
  --readout: #ffb641;        /* sensor readout + ON-toggle orange */
  --error: #e05252;          /* error text / fail LED red */
  --danger: #b84a4a;         /* the mic-recording pulse red */
  --llm-loaded: #00ff00;     /* the LED green, as text: a brain is in memory */
  --panel: #141619;          /* login/settings panel face */
  --radius-card: 20px;
  /* ONE height for every control - buttons, pickers, single-line boxes
     (GK, 16 Aug 2026: "Why are their height mismatches? This should not
     occur. Make the boxes consistent accross the whole app"). It lived
     in three narrow rules before: 44px inside .connect-controls, 41px on
     the setup page only, and nothing at all everywhere else, so a button
     (~33px) and a box (~38px) beside each other genuinely did not match
     on most pages. One token, one rule, no page scoping. */
  --control-h: 41px;
  /* The pitch between selection rows (GK, 16 Aug 2026: "Add 2px between
     the selection boxes") - one token, so the System Selector and the
     connection kinds can never drift apart. */
  --selection-gap: 10px;
  --radius-tile: 14px;
}

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

/* The [hidden] attribute must always win, even over elements that set their
   own display (e.g. .btn uses display:inline-flex, which would otherwise
   override the UA hidden rule). */
[hidden] { display: none !important; }

/* Reserve the scrollbar gutter on every page so a page that scrolls and
   one that doesn't render at the same width - no shift between pages.
   Chrome/Firefox honour this; Safari uses overlay scrollbars (already no
   shift), which is why it looked consistent there. */
html { scrollbar-gutter: stable; }

/* Scrollbars dressed for the app (GK, 21 Jul 2026 - the OS default's square
   white track and grey arrows fought the dark theme). A slim rounded thumb
   on the app's own background, no stepper buttons at all, and the accent
   blue only under the hand - so the bar whispers until it is being used.
   The ::-webkit-* rules cover Chrome/Edge (the bench + phone); the
   scrollbar-* properties are the Firefox fallback - and they MUST stay
   Firefox-scoped: a modern Chrome that sees standard scrollbar-color/width
   switches its ::-webkit-scrollbar styling OFF entirely (measured: the bar
   snapped back to the 15px OS default). The @supports matches Firefox alone. */
@supports (-moz-appearance: none) {
  html {
    scrollbar-color: #333a45 var(--bg, #0b0c0e);
    scrollbar-width: thin;
  }
}
::-webkit-scrollbar {
  width: 11px;
  height: 11px;
}
/* The track is INVISIBLE and inset from its ends (GK, 21 Jul 2026: a solid
   track column ran the full height and cut across the header's border and
   every card corner it met). Transparent, the gutter simply shows whatever
   surface it sits on; the margins stop the pill short of the ends so it
   never touches a border. */
::-webkit-scrollbar-track {
  background: transparent;
  margin: 4px;
}
::-webkit-scrollbar-thumb {
  background: #333a45;
  border-radius: 999px;
  /* A transparent border over background-clip floats the pill off the
     track edges, so it reads as a rounded handle rather than a column. */
  border: 3px solid transparent;
  background-clip: padding-box;
}
::-webkit-scrollbar-thumb:hover {
  background: #4a5260;
  background-clip: padding-box;
}
::-webkit-scrollbar-thumb:active {
  background: var(--accent);
  background-clip: padding-box;
}
::-webkit-scrollbar-button { display: none; height: 0; width: 0; }
::-webkit-scrollbar-corner { background: transparent; }
/* The textarea resize grip (GK, 21 Jul 2026): no box, two diagonal grip
   lines in the scrollbar's own grey. The grip is painted at a FIXED
   10x10px in the corner - the resizer's BOX tracks the scrollbar (11px
   with one, ~16px without), and percentage stops made the grip grow and
   shrink with it (GK: one size, always). */
::-webkit-resizer {
  background:
    linear-gradient(135deg,
      transparent 0 42%,
      #333a45 42% 54%,
      transparent 54% 66%,
      #333a45 66% 78%,
      transparent 78% 100%)
    bottom right / 10px 10px no-repeat;
  border-radius: 0 0 6px 0;
}

body {
  background: var(--bg);
  color: var(--text-body);
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
    "Helvetica Neue", sans-serif;
  /* The app's body text reads 15px site-wide (GK, 23 Jul 2026 - grown
     from the About card's bump): this is the inherited default; the
     deliberate small print (11-13px tags, captions, meta) keeps its own
     explicit sizes as hierarchy. */
  font-size: 15px;
  min-height: 100vh;
  display: grid;
  place-items: center;
}

/* The home hero (index only): centred at the TOP of the page (GK, 23 Jul
   2026 - "In the centre Top") - auto margins centre the block itself, not
   just its text. No box: a border and a fill were both tried and taken
   off; the welcome floats on the page background. */
.hero { text-align: center; padding: 19px 24px 24px; max-width: 560px; width: 100%; margin: 0 auto; }
/* The hero's swirl at 200% (GK, 23 Jul 2026) - the header's stays 64px. */
.hero .brand-logo { height: 128px; }
/* The greeting under the mark (GK, 1 Sep 2026). Italic and NOTHING else:
   it is body text, so it inherits the site's one size, colour and 10px
   paragraph rhythm rather than growing a private treatment. Deliberately
   not bold and deliberately not a heading - GK ruled both. */
.hero-welcome { font-style: italic; }
/* The About card needs no overrides - its 15px size AND its 10px
   paragraph spacing both became the site-wide body-text standard. */

.brand {
  font-size: 13px;
  letter-spacing: 0.35em;
  color: var(--text-secondary);
  margin-bottom: 16px;
}

.brand-dot {
  display: inline-block;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--accent);
  box-shadow: 0 0 10px var(--accent-glow);
  margin-right: 10px;
  vertical-align: 1px;
}

/* Headings stay the bright white while body copy sits a step darker
   (GK, 23 Jul 2026) - the contrast is the hierarchy. */
h1, h2, h3, h4 { color: var(--text); }
/* Every page heading wears the card h2's size (GK, 13 Aug 2026: "same size
   and font as the Camera header") - one heading scale app-wide. */
h1 { font-size: 20px; font-weight: 700; margin-bottom: 12px; }

/* The home status card is gone (GK, 23 Jul 2026) - .micro-label survives
   for the Chat toolbar's tag. Now wears the one heading scale (GK,
   13 Aug 2026: every page heading matches the Camera header). */
.micro-label {
  font-size: 20px;
  font-weight: 700;
  color: var(--text);
  /* No margin: it sits centred in the toolbar's flex row - the old 6px
     was a baseline tweak for the 11px tag and pushed this one off-centre. */
}

/* Footnotes ARE body copy across the app - 15px, body colour, no dim
   treatment, and ONE standard paragraph spacing of 10px (GK, 23 Jul
   2026: "Only H2, H3 etc. should be different"; "It is body text, Make
   it all the SAME!" - the 10px he set on the About card is the app's
   paragraph rhythm, not a per-card exception). The small-print
   hierarchy lives in the 11-13px tag/caption classes, and state colours
   (muted disabled, amber, red) are signals, not footnote styling. */
/* THE paragraph rhythm, app-wide (GK's 23 Jul 2026 law, applied to
   what he actually said on 16 Aug 2026: "I did not say just for
   footnotes class"): EVERY rendered paragraph, one rule, no class
   required. Deliberate exceptions (addresses, notes pinned tight) keep
   their explicit margin overrides - the cascade protects them. It
   cannot reach INSIDE an input box: a textarea shows raw typing, and
   the blank line the owner types is its only paragraph break. */
p { margin-top: 10px; }
.footnote { font-size: 15px; }

/* --- App chrome (Phase 2) --------------------------------------------- */

body.app { display: block; }

.topbar {
  display: flex;
  flex-direction: column;
  width: 100%;   /* span the window on every page (sticky can shrink-wrap) */
  gap: 14px;
  padding: 16px 24px;
  background: rgba(11, 12, 14, 0.9);
  position: sticky;
  top: 0;
  z-index: 10;
}
.topbar-row {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 20px;
}
/* The app clock (GK, 21 Jul 2026): rides the SERVER stage row, left of
   the name and dot, in the stage's own font and size - it inherits
   everything from .stage so the row reads as one line. Boxed (GK):
   4px padding in a 1px border, nudged 5px further left of SERVER. */
.header-clock {
  cursor: default;
  padding: 4px;
  border: 1px solid var(--border);
  border-radius: 4px;
  /* The box must not inflate its row: negative vertical margins equal to
     the padding + border (5px) keep the row's height set by the TEXT, so
     every stage gap stays the LINK-CONTROLLER distance (GK, 20 Jul 2026). */
  margin: -5px 5px -5px 0;
}
/* Collapsible Setup sections (GK, 20 Jul 2026): click the heading to
   fold a card to just its title; a CSS chevron shows which way it is. */
/* ONE zipping section for the whole app (GK, 21 Jul 2026): a container marked
   .collapsible with its heading marked .fold-head. The head is a flex row so
   the chevron is a flex item at its end - which lets a badge or a Customised
   flag sit beside it, and works whether the head is an h2, a div or a label
   (the device page styles labels as flex columns, where a floated chevron
   would drop under the text). Everything but the head hides when folded.
   Behaviour lives in static/fold.js. */
/* The box comes from .form-card - every zipping section carries that class,
   so there is one definition of a section, not copies of its values. This
   rule only handles the HEAD, and it has to out-specify page-level heading
   rules (the device page styles every label as a 13px flex column, which
   would otherwise give Device Details a different head from the rest) - hence
   the doubled class. */
.collapsible > .fold-head.fold-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  /* One head height whether or not a ? button or badge rides in it - the
     controls are the tallest thing (26px), so a bare-text head (About
     FusionCore AI, Zones and Actions) must not sit 5px shorter zipped. */
  min-height: 26px;
  /* The air under every head, set once here (GK, 21 Jul 2026; 12 -> 17px
     23 Jul 2026 - "another 5px" under an open head). The collapsed rule
     below still strips it when folded. */
  margin: 0 0 17px;
  cursor: pointer;
  -webkit-user-select: none;
  user-select: none;
}
.collapsible > .fold-head::after {
  content: "";
  flex: none;
  align-self: center;
  width: 8px;
  height: 8px;
  margin: 0 4px 0 10px;
  border-right: 2px solid var(--text-dim);
  border-bottom: 2px solid var(--text-dim);
  transform: rotate(45deg);
  transition: transform .15s;
}
.collapsed > .fold-head::after { transform: rotate(-45deg); }
.collapsed > .fold-head.fold-head { margin-bottom: 0; }
/* The gap under an OPEN section's head is the h2's 12px and nothing else -
   whatever content comes first (a footnote, a settings row, a textarea) must
   not add its own top margin, or every section opens differently. */
.collapsible > .fold-head + * { margin-top: 0; }
/* Several cards keep a HIDDEN help paragraph right after the head - the
   first VISIBLE content sits one sibling later and must lose its top margin
   the same way, or those sections open with a double gap. */
.collapsible > .fold-head + [hidden] + * { margin-top: 0; }
/* The title text stretches, so anything else in the head (a Customised flag)
   and the chevron sit at the right edge. */
.fold-head > .fold-title { flex: 1; }
/* The collapse is a STATE and must always win, like the global [hidden]
   rule: a strongly-selected child (the device page's label rules
   out-specify a plain class selector) must not stay painted on a folded
   card - the leak GK caught on the Options checkbox (22 Jul 2026). */
.collapsed > *:not(.fold-head) { display: none !important; }
/* ⭐ CLOSED BEFORE THE SCRIPT RUNS (GK, 2 Sep 2026: "some of the accordions
   open and then close during the startup, can we make that not happen").
   They did open: a foldable card is only MARKED closed when fold.js reaches
   it, and the browser has already painted the page by then - so every card
   flashed open and snapped shut. These rules close them in CSS, on the same
   selectors fold.js uses, and stop applying the instant it marks the card
   ``collapsible`` - after which the state above is the only thing speaking.
   Keep the two lists in step: a page that folds a new container needs its
   selector here, or its cards flash again. */
/* ⚠⚠ SCOPED TO data-fold-cards, AND THAT IS LOAD-BEARING (GK's bench, 2 Sep
   2026: a device page showing nothing but its Back button and Remove card).
   The first cut used a bare `.page >` selector - but `.page` is the container
   on a dozen templates and only TWO of them fold every card. On the device,
   automations, notices and exdev pages the cards are never marked
   `collapsible`, so this rule hid their contents FOR EVER - a cosmetic fix
   that broke real pages. A pre-JS rule must match exactly the cards JS is
   about to fold and nothing else, so the two pages that fold everything now
   say so in their markup. */
[data-fold-cards] > .form-card:not(.always-open):not(.collapsible) > *:not(h2),
#guest-portal > .form-card:not(.collapsible) > *:not(h2),
#share-edit > .form-card:not(.collapsible) > *:not(h2) {
  display: none !important;
}
/* ⭐⭐ AND THE HEAD STARTS ITS FINAL SIZE (GK, 2 Sep 2026: "when I enter a
   page, the accordions are one size, then they change a few moments later").
   Hiding the body above stopped the cards flashing OPEN, but the heading kept
   restyling: the flex row, the 26px min-height, the chevron and the collapsed
   zero margin all arrive with the ``collapsible`` class, so every card visibly
   resized the moment fold.js reached it. These mirror the head rules above -
   in their COLLAPSED form, because closed is what the page opens as - so the
   first paint is already the finished shape and nothing moves.
   ⚠ Keep this in step with `.collapsible > .fold-head` above: a change there
   that is not made here brings the resize straight back. */
[data-fold-cards] > .form-card:not(.always-open):not(.collapsible) > h2,
#guest-portal > .form-card:not(.collapsible) > h2,
#share-edit > .form-card:not(.collapsible) > h2 {
  display: flex;
  align-items: center;
  justify-content: space-between;
  min-height: 26px;
  margin: 0;
  cursor: pointer;
  -webkit-user-select: none;
  user-select: none;
}
[data-fold-cards] > .form-card:not(.always-open):not(.collapsible) > h2::after,
#guest-portal > .form-card:not(.collapsible) > h2::after,
#share-edit > .form-card:not(.collapsible) > h2::after {
  content: "";
  flex: none;
  align-self: center;
  width: 8px;
  height: 8px;
  margin: 0 4px 0 10px;
  border-right: 2px solid var(--text-dim);
  border-bottom: 2px solid var(--text-dim);
  transform: rotate(-45deg);
}
/* Sub-headings inside a card (GK, 22 Jul 2026): ONE rule for every card's
   h3, so any section that groups its contents does it identically. Sized
   into the card's own scale - h2 16, h3 15 semibold bright, captions 15
   regular secondary (the first small-caps cut read ridiculous under the
   bigger captions - GK). The generous top margin is what separates the
   groups; the fold rules strip it from the first heading so a section
   still opens flush. */
.form-card h3 {
  font-size: 15px; font-weight: 600; color: var(--text);
  margin: 28px 0 20px;
}
/* A HELP BOX at the bottom of a card (GK, 24 Jul 2026: "put those details
   in a help box at the bottom of the Accordion box") - set-up instructions
   that are needed once and then never again, so they sit under the fields
   rather than in front of them. Distinguished by the BOX, not by shrinking
   the type: body text is body text (GK's standing rule) - 15px, the app's
   own colour, the same 10px paragraph rhythm. Its own h3 overrides the
   card's generous group spacing, which is sized to separate whole groups.
   Reusable: any card needing standing instructions uses this, never a
   private variant.

   ⚠ NO CARD USES IT TODAY. Its only user was the Email card's Gmail
   walkthrough, removed 24 Aug 2026 when GK moved that detail to the
   FusionCore Manual - and the same note asks for a [ Help ] button
   "later on", which is what this is for. Kept deliberately, not
   forgotten: if the Help work is dropped, drop this with it. */
.help-box {
  margin-top: 20px;
  padding: 14px 16px;
  background: var(--surface-raised);
  border: 1px solid var(--border);
  border-radius: 10px;
}
.help-box h3 { margin: 0 0 10px; }
.help-box p { margin: 10px 0 0; line-height: 1.5; }
.help-box p:first-of-type { margin-top: 0; }
/* The numbers sit in the padding, so the text block lines up with the
   prose above and below it rather than stepping in and out. */
.help-box ol { margin: 10px 0 0; padding-left: 22px; }
.help-box li { margin-top: 6px; line-height: 1.5; }
.help-box li:first-child { margin-top: 0; }
/* An h2 head keeps its own bottom margin when open - the shared rule only
   removes it once folded. */
/* The heading is capped to the content area's width (GK item 6, 20 Jul
   2026): the bar's background still spans the window, but its brand, stages
   and nav align with the page content (960px - 2*24px padding = 912px). */
.topbar-row,
.topbar .nav {
  width: 100%;
  max-width: 912px;
  margin-left: auto;
  margin-right: auto;
}

/* Logo (swirl) + two-line FUSION CORE wordmark, nudged down 10px so it
   sits a touch below the status stages. */
.brand {
  display: flex;
  align-items: center;
  gap: 16px;
  text-decoration: none;
  margin-top: 10px;
}
.brand-logo { height: 64px; width: auto; display: block; }
/* THE LOGO WEARS THE ALARM (GK, 27 Aug 2026: "Maybe we could even make
   the logo turn red! That would be cool."). The logo is raster art - a
   blue gradient swirl in a webp - so recolouring the artwork itself would
   be a crude hue-mangle; it BREATHES a red glow instead: the status-lamp
   pure red (#ff0000, the .dot-bad lamp - red is a PROVEN problem), on a
   slow calm pulse. Urgent, never strobing. Standing P1-P3 alarms light
   it; a bare Notice does not - same law as the Alerts entry. */
.brand-logo.brand-alarm {
  animation: brand-alarm-breathe 2.6s ease-in-out infinite;
}
@keyframes brand-alarm-breathe {
  0%, 100% { filter: drop-shadow(0 0 3px rgba(255, 0, 0, 0.55)); }
  50% { filter: drop-shadow(0 0 10px rgba(255, 0, 0, 0.95)); }
}
@media (prefers-reduced-motion: reduce) {
  /* Still red, just still: the glow stays, the breathing stops. */
  .brand-logo.brand-alarm {
    animation: none;
    filter: drop-shadow(0 0 8px rgba(255, 0, 0, 0.8));
  }
}
.brand-word {
  position: relative;
  top: 2px; /* text sits 2px lower than the logo centre */
  /* Eurostile MN Bold where installed (self-host the file to guarantee it
     on every device - see INSTALL/notes); clean sans fallback otherwise. */
  font-family: "Eurostile MN Extended Bold", "Eurostile MN",
    "Eurostile LT Std", "Eurostile", "Helvetica Neue", Arial, sans-serif;
  font-size: 25px;
  font-weight: 700;
  line-height: 0.94;
  letter-spacing: 0.06em;
  color: var(--text-secondary);
}

.nav { display: flex; gap: 22px; flex-wrap: wrap; margin-top: 0; }
.nav-link {
  color: var(--text-dim);
  text-decoration: none;
  font-size: 15px;
  font-weight: 600;
  padding: 4px 0;
}
.nav-link:hover { color: var(--text-secondary); }
.nav-link.active { color: var(--text); }
/* THE ALERTS ENTRY TURNS RED while alarms stand (GK, 27 Aug 2026: "Can
   you make the apps Alerts index text turn red if there are alerts").
   The status-lamp pure red (#ff0000, the .dot-bad lamp) - red in this
   app is a PROVEN problem, so only standing P1-P3 ALARMS light it and a
   plain Notice never does. One element serves both menus (the phone nav
   is this same row, swiped). Declared after .active and :hover, and
   naming both, so the alarm outranks them - an alarm is not dimmed by
   being hovered or the open page. */
.nav-link.nav-alarm,
.nav-link.nav-alarm:hover,
.nav-link.nav-alarm.active {
  color: #ff0000;
  text-shadow: 0 0 8px rgba(255, 0, 0, 0.45);
}

/* Three status stages, stacked and right-aligned: LABEL then dot. */
.stages { display: flex; flex-direction: column; align-items: flex-end; gap: 6px; }
.stage {
  display: flex;
  align-items: center;
  gap: 8px;
  margin: 0;
  font-size: 11px;
  letter-spacing: 0.08em;
  color: var(--text-secondary);
}
.stage-name { text-transform: uppercase; }
.stage-dot {
  display: inline-block;
  width: 9px;
  height: 9px;
  border-radius: 50%;
  background: var(--text-dim);
}
/* Status LEDs: pure green = OK, pure red = a proven problem - read at a glance
   like real indicator lamps, so both glow the same way (GK, 17 Jul 2026). Not
   --accent: the electric blue is the UI's action colour, not a health signal. */
.stage-dot.dot-good { background: #00ff00; box-shadow: 0 0 6px 1px #00ff00, 0 0 16px 4px rgba(0, 255, 0, 0.9); }
.stage-dot.dot-bad { background: #ff0000; box-shadow: 0 0 6px 1px #ff0000, 0 0 16px 4px rgba(255, 0, 0, 0.9); }
/* CHOSEN, not yet proven: the accent blue, lit like its siblings (GK,
   16 Aug 2026: "highlighted maybe with a bright blue LED first. then if
   connected it goes bright green"). It cannot be mistaken for a health
   signal - blue has always been this app's colour for what the owner
   or the UI is doing, green for what is actually true. */
.stage-dot.dot-chosen { background: var(--accent); box-shadow: 0 0 6px 1px var(--accent), 0 0 16px 4px var(--accent-glow); }
/* The Description paragraph box (GK, 16 Aug 2026): holds a paragraph
   or two and GROWS to what is typed (setup.js sets the height; manual
   resize off so the two never fight). Wider than the one-line boxes -
   a paragraph in a 280px box reads like a keyhole. */
.sys-desc-box {
  /* Full width, like every other details/description box in the app -
     the device page's Device Details, the share page's Instructions
     (GK, 24 Aug 2026: "it should be like all of the other details
     boxes"). It lost its width when the .field-row grid went with the
     caption; there is NO cap - the earlier 560px was invented. */
  width: 100%;
  min-height: 3.2em;
  resize: none;
  overflow: hidden;
  /* Typed paragraphs breathe inside the box too - the one place the
     paragraph rule cannot reach, so the line spacing does the work. */
  line-height: 1.5;
}

/* The same lamp, seated in the Connection card's mode labels and on the
   system rows (GK, 16 Aug 2026) - one LED family, never a copy. And ONE
   circle per row (GK: "Why have two leds?"): the radio input stays as the
   MECHANISM - keyboard, screen reader, click - but hides, so the lamp is
   the only circle. Which row is selected already reads from the row's own
   active styling; which MODE is chosen reads from the accent on its label. */
/* The row's own 12px gap seats the lamp now - no side margins to
   fight it (they were tuned for the old inline layout). */
.conn-mode-choice .stage-dot, .system-row-dot { flex: none; }
.system-row input[type="radio"],
.conn-mode-choice input[type="radio"] {
  position: absolute;
  opacity: 0;
  width: 0;
  height: 0;
}
/* One choice per line, Remote under Direct (GK, 16 Aug 2026). The
   words are plain body text - GK: "I dont like the blue text being
   used" - because the LED beside them is what says which is chosen.

   ⚠ The gap is --selection-gap, the SAME variable the system rows use,
   because these are the same idiom: a stack of rows you choose between.
   One variable, so the two families can never drift apart. This rule was
   deleted in a cleanup that believed the wrapper had gone, and then the
   wrapper came back in the rebuild without it - which left the two mode
   rows touching at 0px against the selector's 10px (GK, 24 Aug 2026:
   "still stupid spacing between direct and remote connections"). */
.conn-modes { display: flex; flex-direction: column; gap: var(--selection-gap); }

/* THE PATH, DRAWN (GK, 24 Aug 2026): "O FusionCore ----- O Server -----
   O Link ----- O Controller ... The O is a led."

   Two signals, deliberately different so they cannot blur:
     the LEDS are evidence - green proven, red faulted, grey not asked;
     the BARS are the choice - accent blue when this is the path actually
     carrying traffic, grey when it is not.
   Blue for chosen and green for proven is the app's standing colour law,
   not a new idea invented for this picture.

   It wraps on a narrow screen, and the bars shrink before the labels do,
   because the labels are the part that has to stay readable. */
.conn-chain {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 4px 0;
}
/* ~20px more air than the card's 16px block rhythm, top and bottom (GK,
   24 Aug 2026). The diagram is the card's headline, not one of its blocks -
   giving it room is what lets the eye land on it first.

   ⚠ Written as `.form-card > .conn-chain`, NOT on .conn-chain alone: the
   in-card rhythm rule `.form-card > * + *` has the SAME specificity and
   comes later in the file, so it was quietly winning and the top gap stayed
   at 16 while the bottom went to 36. Measured, not assumed. Adjacent
   margins collapse, so 36 wins over the neighbours' 16 rather than adding. */
.form-card > .conn-chain {
  margin-top: 36px;
  margin-bottom: 36px;
  /* The first LED lines up with the mode rows' LEDs above it (GK, 24 Aug
     2026: "FusionCore Server led should be in line with Local Connection
     LED"). The selection rows inset their lamp by their own 14px padding
     plus a 1px border; the chain has no box, so it carries that 15px
     itself. MEASURED - the offset was exactly 15px. */
  padding-left: 15px;
}
.chain-node { display: flex; align-items: center; gap: 7px; flex: none; }
.chain-label {
  font-size: 15px;
  color: var(--text-secondary);
  white-space: nowrap;
}
/* An askable box IS a button (GK, 24 Aug 2026: "Should we just make
   O [ Link ] the button"). Sized to the chain's own 13px label so turning
   a box into a button does not change the height of the row, and only the
   boxes you can actually ask are buttons - which is what makes being a
   button meaningful here. */
.conn-chain .chain-node-btn {
  /* 5px off each side (GK, 24 Aug 2026) - a box in the chain wants to read
     as a node, not as a form button.
     ⚠ Written as `.conn-chain .chain-node-btn`, not `.chain-node-btn`:
     `.btn` has the SAME specificity and is defined LATER in this file, so
     the bare selector lost and the padding never changed. Second time this
     trap has bitten today - the chain's own margin needed the same fix. */
  padding: 8px 11px;
  font-size: 15px;
  height: auto;
  /* Darker than the label (GK, 24 Aug 2026) - the box should hold the
     word, not compete with it - and 2px, so a quieter colour still draws
     a definite box. The 1px is absorbed by the padding so the button does
     not grow. */
  border: 2px solid var(--chain-line);
  padding: 7px 10px;
  /* Clear of its lamp (GK, 24 Aug 2026: "Link and controller buttons are
     too close to the lights"). The node's own 7px gap is right for a plain
     label, but a BORDERED box needs more: the LED's glow spills past its
     8px body, so the border was landing in the halo. +6px on top of the
     node gap. */
  margin-left: 6px;
}
.conn-chain.chain-live .chain-node-btn { color: var(--text); }
/* ⚠ ONE colour for the boxes' borders and the wires between them (GK,
   24 Aug 2026: "make the connecting lines the same colour as the
   borders"), so the diagram is drawn in a single line weight and tone and
   the two can never drift apart. */
.chain-bar {
  /* Shorter and finer (GK, 24 Aug 2026: "shorten the connection lines as
     well. Maybe make the lines green, 1px high") - the wire is a join, not
     a feature, so it should read as the thinnest thing on the row. */
  flex: 0 0 auto;
  width: 18px;
  height: 1px;
  margin: 0 7px;
  background: var(--chain-line);
}
/* The live path: its wires light GREEN and its labels come up to full
   text, so at a glance the lit chain IS the structure.

   ⚠ GK chose green here (24 Aug 2026), which reads the wire as EVIDENCE -
   the same green as the LEDs - rather than as the blue "chosen" colour I
   had used. That is the more natural reading of a wire: a green line is a
   working connection, and the whole picture is then one language instead
   of two. Which path is merely SELECTED is said by the Select/Auto buttons,
   so nothing is lost by the bars not carrying it. */
/* ⬜ The live wires were GREEN (GK asked for that earlier the same day);
   they now follow the borders, which is what he asked for last. If a live
   path should still stand out in the picture itself, this is the one line
   to change back - the LEDs still carry it either way. */
.conn-chain.chain-live .chain-bar { background: var(--chain-line); }
.conn-chain.chain-live .chain-label { color: var(--text); }

/* "( Connected )" beside a kind's name (GK, 24 Aug 2026: "if it connects,
   it should be USB Controller ( Connected )"). Deliberately quiet - it is
   a fact about that row, and the chain above is where the eye goes for
   the whole picture. */
.conn-kind-state {
  /* Same font and colour as the kind's own name (GK, 24 Aug 2026) -
     "via Link ( Connected )" is one phrase, so it should not read as a
     name followed by a smaller, dimmer annotation. */
  font-size: inherit;
  font-weight: 600;
  color: var(--text);
  white-space: nowrap;
}

/* WHICH direct path to use - now three buttons in a row on each kind
   (GK, 24 Aug 2026: "Maybe we make three buttons: Connect, Select, Auto",
   "all after each other. remove the pills at the bottom"). Connect is the
   action; Select and Auto are a CHOICE, so the chosen one wears the same
   accent tint the chips use - one active-state look across the app, not a
   second one invented for buttons. */
/* THE CHOSEN ONE IS LIGHTER, and nothing else changes (GK, 25 Aug 2026:
   "make the background a little lighter, and leave the text the same for
   all"). A pair of buttons where one is active reads best as a LIFT, not as
   a different kind of button: same label colour, same weight, same size, so
   the eye compares two identical things and sees which one is raised.

   ⚠ It started as a 14% wash of the accent, which did not read at all - GK
   could not tell the Link was PINNED, so a stale pin quietly overrode the
   USB-first rule he had just asked for and it looked like the rule was
   broken. The first correction over-shot the other way (a solid accent
   slab, white bold text); this is the middle, and it is his call. */
/* ⚠⚠ `.btn.btn-on`, NOT `.btn-on`. This is the 24 Aug 2026 lesson, paid for
   a second time and much more expensively: a variant needs MORE specificity
   than the component it varies. `.btn-on` and `.btn` are both single classes
   - equal specificity - and `.btn` sits ~190 lines BELOW here, so it won and
   quietly reset background, colour and border on every "chosen" button.
   The highlight was never rendering. GK said "can't tell the difference"
   three times, through a 14% accent wash, a solid accent slab with white
   bold text, and a lighter face - and he was right every time, because none
   of the three ever reached the screen. I kept redesigning what was not
   being drawn instead of asking why nothing changed. */
.btn.btn-on {
  background: var(--surface-chosen);
  border-color: var(--border-strong);
}
.btn.btn-on:hover { background: var(--surface-chosen); }
.conn-mode-choice, .system-row-main { cursor: pointer; }

/* GK's Connection card, 24 Aug 2026: two headed groups (Direct
   Connection, Remote Connection) and, inside them, one block per
   connection KIND - the Controller's own USB, the Link's USB, the
   broker. Each kind is its head row plus the fields that kind needs,
   so a kind reads as one thing instead of a lamp floating above a
   shared pile of boxes.

   The head row is the same lamp-then-name shape the mode labels and the
   system rows already use, so this is a new arrangement of the existing
   idiom rather than a fourth way of drawing a choice. */
/* ONE rhythm inside the Connection card (GK, 24 Aug 2026: "Spacing
   should be consistent through the app"). The app law is 16px between a
   card's blocks (GK, 16 Aug), but the mode-detail wrappers break the
   .form-card > * + * chain and the kinds sat on a mix of 10/16/20. These
   two rules put every block back on 16px - the wrapper's own children,
   and each kind's head/fields/note - while the caption-to-box gap inside
   a .field-rows keeps its own tighter 10px (it is not a block gap). */
#direct-details > * + *,
#remote-details > * + *,
.conn-kind > * + * { margin-top: 16px; }
/* One kind clears the next by 20px more than the in-card rhythm (GK,
   24 Aug 2026) - the gap between two KINDS has to read as bigger than the
   gaps inside one, or the buttons of the first look like they belong to
   the heading of the second. Two classes plus the id, so it beats the
   `> * + *` rule above rather than tying with it. */
#direct-details > .conn-kind + .conn-kind { margin-top: 36px; }
.conn-kind { margin-bottom: 0; }
/* Every kind's head sits on the same row shape whether it is a selectable
   choice or not, so the three read as three peers rather than two boxed
   rows and a caption. The radio kinds add .conn-mode-choice on top for
   the pointer and the lamp; this is the shape they share. margin-bottom
   is 0 - the rhythm rule above owns the gap to the fields. */
.conn-kind-head {
  display: flex;
  align-items: center;
  gap: 12px;
  margin-bottom: 0;
  min-height: 24px;
}
/* The kind's name carries the heading colour while its fields stay body
   text - the same contrast that makes every other card scannable. */
.conn-kind-name { color: var(--text); font-weight: 600; }
/* What each connection method NEEDS before it can work (GK, 2 Sep 2026).
   Quiet, numbered, and directly under the name - the app can only ever see
   its own half of these, so when a method will not come up they are the
   first thing to read rather than something to be told afterwards. Sized
   with the captions, not the controls: they inform, they do not compete. */
.conn-kind-needs {
  margin: 6px 0 12px;
  padding-left: 0;
  /* Left-justified with the heading ( GK, 2 Sep 2026 ) - the numbers sit
     inside the block rather than the text being pushed in, so it lines up
     with the section name above and the field labels below. */
  list-style-position: inside;
  color: var(--text-dim);
  font-size: 13px;
  line-height: 1.5;
}
.conn-kind-needs li { margin: 2px 0; }
/* The group headings sit above their kinds; the card's own h3 rule gives
   them their size, this only stops the FIRST one pushing a gap under the
   card title that no other card has. */
.conn-kind + h3, #connection-card h3:first-of-type { margin-top: 0; }
/* A kind that cannot be chosen is not a pointer - the Controller's row
   names a cable, it does not select a connection (yet). */
p.conn-kind-head { cursor: default; }

/* The single page container: fills to a fixed max-width and centres, so
   every page is exactly the same width (was shrink-wrapping to content). */
/* Top padding matches the Raw page (16px) so every page starts the same
   distance below the header. */
.content { width: 100%; max-width: 960px; margin: 0 auto; padding: 16px 24px; }
/* The hero sits at the TOP now the home page carries real cards below it
   (GK, 23 Jul 2026) - the old 10vh drop suited an empty landing page. */
.hero { margin-top: 0; }

/* The nav names the page, so page titles are gone (Decision 33); the
   page-head now holds just its controls, kept to the right. */
.page-head {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  flex-wrap: wrap;
  gap: 12px;
  /* 12px under every heading app-wide (GK, 13 Aug 2026). */
  margin-bottom: 12px;
}

/* Actions page: the New Action / Import / Export controls sit below the
   tiles, so give them clear separation from the grid above. */
body.actions-page .page-head { margin-top: 36px; justify-content: flex-start; }
/* A page led by its Back button (GK, 21 Jul 2026 - AI Setup): the blue box
   sits top-LEFT, the title follows it - on ONE line: the global h1 keeps a
   28px bottom margin that shoved the title off the button's centreline, and
   34px read too big beside a small button (GK: drop 5). */
.page-head-back { justify-content: flex-start; }
/* Every page-level heading starts on the SAME line as a card's heading:
   a card head sits 19px below the content top (1px border + 18px padding),
   so bare heads carry that 19px themselves (GK, 13 Aug 2026: "MAKE THEM
   ALL THE SAME"). One rule for every bare head - never a per-page copy. */
.map-head, .dash-head { margin-top: 19px; }
/* The back-led head is 9px less: its title centres against the taller Back
   button (~45px vs the title's ~27px), which pushes the title's TOP down
   (45-27)/2 = 9px - measured at 44 when the row sat at 19. 10px puts the
   title's top on the same 35px line as every other heading. */
.page-head-back { margin-top: 10px; }
/* â­ Bigger (GK, 10 Aug 2026: "make the back to map bigger"). It is the way
   OUT of a page reached from somewhere else, so it should be as easy to hit
   as the thing that got you here - and on a phone that means a real target,
   not a 12px chip. One rule, so every Back in the app is the same size. */
/* The arrow is CSS, not an &larr; in each template (GK, 24 Aug 2026: the
   Back button "should be consistent with the rest of the site. Currently
   the arrow is too small and too low").

   ⭐ REDRAWN AND RE-SEATED (GK, 1 Sep 2026: "Can you make the arrow have
   some authority and make the word and arrow line up").

   ⚠ WHY THEY NEVER LINED UP. The label sits 2px ABOVE centre - that is
   .btn's deliberate top/bottom padding asymmetry (GK, 23 Jul 2026:
   "raise the text 2px") - while the arrow was a TEXT GLYPH chasing it
   with a vertical-align of its own. Two mechanisms hunting one position,
   and a font's arrow glyph sits wherever that font decides. The button is
   a FLEX ROW now, so the arrow and the word centre on ONE ANOTHER by
   construction and cannot drift apart. GK's 2px lift is untouched - the
   padding still raises the pair together.

   ⚠ AUTHORITY IS STROKE WIDTH, and a font glyph has none to give: U+2190
   is drawn hairline in most faces and no font-weight will thicken it. So
   it is an SVG with a round-capped stroke, carried as a MASK
   rather than a background image so it paints in currentColor and stays
   right on any button colour. Inline, never a fetched file - the app is
   offline-first, the same reasoning as the select chevron.

   ⚑ WEIGHT DIALLED BACK the same day - the first cut was 21x16 at stroke
   3 and GK called it "too heavy or big now". 17x13 at stroke 2.2 is the
   one he was after: it still reads as a drawn arrow rather than a hairline
   glyph, but it sits in proportion to a 15px label instead of shouting
   over it. Four weights were rendered side by side and looked at before
   choosing - the same reflex as the sound work, since neither "authority"
   nor "too heavy" is a thing you can settle by reading CSS.

   The accessible name is unaffected: the content is empty, so a screen
   reader hears "Back", not "leftwards arrow Back". */
.page-head-back .btn::before {
  content: "";
  flex: none;
  width: 17px;
  height: 13px;
  /* Sits 1px LOWER than dead centre (GK, 1 Sep 2026: "drop the arrow
     height by 1px" / "the vertical height in the box" - the arrow's SEAT
     in the button, not its size).

     ⚠ Why it needs saying at all: flex centres the arrow's BOX on the
     label's box, but the label's own glyphs do not fill their box evenly
     - "Back" has an ascender and no descender, so its ink sits high
     inside its line. Centring the boxes therefore leaves the arrow
     reading a touch high against the ink beside it. This is the
     correction, and it is a visual seat, not a geometric one. */
  position: relative;
  top: 1px;
  margin-right: 10px;
  background: currentColor;
  -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 18'%3E%3Cpath d='M22 9H3M10 2L3 9l7 7' fill='none' stroke='%23000' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") no-repeat center / contain;
  mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 18'%3E%3Cpath d='M22 9H3M10 2L3 9l7 7' fill='none' stroke='%23000' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") no-repeat center / contain;
}
.page-head-back .btn {
  /* inline-flex is what gives the arrow and the label one shared centre
     line; align-items centres the two ITEMS against each other. */
  display: inline-flex;
  align-items: center;
  font-size: 15px;
  /* Keeps .btn's 4px top/bottom asymmetry, which is what lifts the label 2px
     off centre (GK, 23 Jul 2026) - only the amount grows. Lands at ~45px
     tall, over the 44px a thumb actually wants. */
  padding: 12px 22px 16px;
  border-radius: 12px;
}
/* +25px on top of the head's 12px gap between Back and the title (GK).
   Size comes from the global h1 (13 Aug 2026 unification). */
.page-head-back h1 { margin-bottom: 0; margin-left: 25px; }
/* Add Action: a lighter grey than the other buttons so it subtly stands
   out as the primary create control, without the full accent blue. */
body.actions-page #new-btn { background: #2b2f37; border-color: #3a404b; }
body.actions-page #new-btn:hover { background: #333843; }

.connect-controls { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
/* Reset Controller sits 30px clear of the settings above it (GK, 24 Aug
   2026). It reboots the box - the one genuinely destructive control on
   this card - so it should not read as the next item in a row of
   connection settings. The id beats the card's own `> * + *` rhythm rule,
   which is otherwise the thing that would flatten it back to 16px. */
#connection-card > .conn-reset-row { margin-top: 46px; }
/* The control height is --control-h, set once at the top of this file
   and applied below - the three narrow rules that used to live here
   (44px in this row, 41px on the setup page, nothing elsewhere) are what
   made controls disagree from page to page. The stale .connect-selector
   block went with them: it made the picker a full-width block with an
   8px bottom margin, which inside its centred row lifted it 4px above
   the Scan button beside it - the mismatch GK photographed. */

.btn,
.select,
input.input-basic { height: var(--control-h); }
/* A paragraph box grows - it takes the shared look, never the shared
   height (the Description box, the AI Setup fields, the chat compose). */
textarea.input-basic { height: auto; }
/* .btn-small keeps its own compact size on purpose - list rows. */
.btn-small { height: auto; }
/* A button in a list row that has to be FOUND (GK, 24 Aug 2026, of the
   guest list's Edit: "Make the Edit button outlines a little lighter so
   that the button is easier to see"). The standard button border is tuned
   for a form, where a button sits alone under its fields; in a row of text
   it disappears. Lighter border only - no fill, no accent - because it is
   still a secondary action beside the row's name. */
/* ⚠⚠ COMPOUND, for the same reason as .btn.btn-on above. This is GK's own
   24 Aug 2026 item - "Make the Edit button outlines a little lighter so
   that the button is easier to see" - and it was committed as done while
   NEVER RENDERING: `.btn-outline` is a single class, `.btn` is a single
   class, and `.btn`'s `border` shorthand sits below here, so it won.
   Found 25 Aug 2026 by the guard written after the same fault ate three
   rounds of work on the Connection card's chosen button. */
.btn.btn-outline { border-color: #4a515d; }
.btn.btn-outline:hover { border-color: #5c6472; }

.select, .input-basic {
  background: var(--surface);
  color: var(--text);
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 9px 12px;
  /* Form values are body text - 15px with the site (GK, 23 Jul 2026);
     the phone media block still forces 16px (the iOS focus-zoom rule). */
  font-size: 15px;
  /* Inherit the app font (GK, 23 Jul 2026): without this a <textarea>
     falls back to its UA default (often monospace) while <input> uses the
     system font - the compose box then looked unlike every other field.
     Monospace fields set their own family with more specific selectors. */
  font-family: inherit;
}
/* ⭐ THE CHEVRON, APP-WIDE (GK, 1 Sep 2026: "Can you space the arrow away
   from the edge please").

   ⚠ The NATIVE select arrow sits hard against the right edge and cannot be
   moved - padding-right does not shift it. So the only way to control that
   gap is to draw the arrow ourselves. The filter bar worked this out first
   and solved it for the filter bar ALONE; every other select in the app
   kept the cramped native one. This is that same fix, applied where it
   should have been in the first place - one rule, every select.

   Inline SVG, never a file: the app is offline-first and nothing it needs
   to draw may come off the internet. 16px from the edge, with the padding
   holding the text clear of it. */
.select {
  appearance: none;
  -webkit-appearance: none;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' fill='none' stroke='%239aa0a8' stroke-width='2'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right 16px center;
  padding-right: 40px;
}

/* Themed focus: ONE clean accent border, nothing else (GK, 26 Aug 2026:
   the soft glow ring read as "a very light duplicated blue boundary line"
   - the box you are working in simply turns blue). Still a visible change
   for keyboard focus - FDB accessibility. */
.select:focus, .input-basic:focus {
  outline: none;
  border-color: var(--accent);
  box-shadow: none;
}

.btn {
  background: var(--surface);
  color: var(--text);
  border: 1px solid var(--border);
  border-radius: 10px;
  /* Label sits 2px above centre (GK, 23 Jul 2026: "raise the text 2px")
     - less top than bottom padding shifts it up; total 20px keeps the
     button height unchanged. */
  padding: 7px 16px 11px;
  font-size: 14px;
  /* Buttons, like textareas, do not inherit the page font by default -
     make them, so every control across the app reads in one font (GK,
     23 Jul 2026). */
  font-family: inherit;
  /* Tight line box so the label centres on the em, not on the font's
     asymmetric ascent/descent (GK, 23 Jul 2026). */
  line-height: 1;
  cursor: pointer;
  text-decoration: none;  /* anchors styled as buttons (e.g. Download log) */
  display: inline-flex;
  align-items: center;
  justify-content: center;
}
.btn:hover { background: var(--surface-raised); }
.btn:disabled { opacity: 0.4; cursor: default; }
.btn-primary {
  background: var(--accent);
  border-color: var(--accent);
  color: #fff;
}
.btn-primary:hover { background: #3d78ff; }
/* Disconnect: a muted red - clearly a "stop" action, not alarming, but a
   proper red rather than brown. */
.btn-danger {
  background: #b23636;
  border-color: #c85252;
  color: #fff;
}
.btn-danger:hover { background: #c24040; }

/* A GREEN TICK on a connected kind's heading (GK, 24 Aug 2026: "Or a
   green tick ... so that it does not confuse"). Deliberately NOT a lamp:
   the diagram directly above is made of LEDs, and a lamp here would read
   as another node in it. A tick is a different visual language, so the
   two cannot be mistaken for one another.

   The Connect button briefly went green and read "Connected" instead;
   that put the state and the action in one control, and the tick is the
   better split - the heading says what IS, the buttons say what you can
   DO. */
.conn-kind-tick {
  color: #00ff00;
  /* Heavier (GK, 24 Aug 2026). A tick glyph has thin strokes at body
     weight, so it needs both the size and the weight to read as a mark
     rather than a stray character. */
  font-size: 17px;
  font-weight: 700;
  line-height: 1;
}

/* A QUIETER RED for Disconnect (GK, 24 Aug 2026: "the red should be a
   little less loud", then "Make the disconnect buttons dull red like the
   off buttons"). Releasing a cable is reversible in one press - it is not
   the same act as Reset Controller or deleting a share, which the loud
   filled red is really for.

   ⭐ These are the OUTPUT OFF button's exact colours (.out-off), not a
   third red invented for this card: the app already had a "dull red" and
   it means the same thing there - a plain, reversible off. Borrowing it
   keeps two reds in the app rather than three, and the loud one keeps
   meaning what it always meant. */
.btn.btn-quiet-danger {
  background: #7e3535;
  border-color: #944444;
  color: #f2e6e6;
}
.btn.btn-quiet-danger:hover:not(:disabled) { background: #8e3c3c; }

/* A connected link's Connect button: darker blue, clearly "already done". */
.btn-primary:disabled {
  background: #1e3a80;
  border-color: #1e3a80;
  color: rgba(255, 255, 255, 0.55);
  opacity: 1;
}
.btn-small { padding: 5px 10px; font-size: 12px; }
/* Mic button while recording: a clear red pulse so the owner knows it is
   live and listening (and how to stop). */
.btn.recording { background: var(--danger); color: #fff; animation: mic-pulse 1.2s ease-in-out infinite; }
@keyframes mic-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.55; } }

.console-card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-card);
  overflow: hidden;
}
.console-toolbar {
  display: flex;
  align-items: center;
  gap: 16px;
  padding: 10px 16px;
  border-bottom: 1px solid var(--border);
}
.live-badge {
  font-size: 11px;
  letter-spacing: 0.2em;
  color: var(--accent);
  border: 1px solid var(--accent);
  border-radius: 6px;
  padding: 2px 8px;
}
.live-badge.paused { color: #e8a13c; border-color: #e8a13c; }
.toggle { font-size: 13px; color: var(--text-secondary); }
/* Checkboxes follow the dark theme: dark fill, lighter border, accent tick. */
.toggle input[type="checkbox"] {
  appearance: none;
  -webkit-appearance: none;
  width: 15px;
  height: 15px;
  border: 1px solid #3d434c;
  border-radius: 4px;
  background: var(--surface-raised);
  cursor: pointer;
  position: relative;
  vertical-align: -3px;
  margin-right: 4px;
}
.toggle input[type="checkbox"]:hover { border-color: #545b66; }
.toggle input[type="checkbox"]:checked::after {
  content: "";
  position: absolute;
  left: 4px;
  top: 1px;
  width: 4px;
  height: 8px;
  border: solid var(--accent);
  border-width: 0 2px 2px 0;
  transform: rotate(45deg);
}

.console-out {
  /* An inset, bordered transcript frame - matching the AI chat log and
     the guest Messaging box, so the chat-like surfaces read alike
     (GK, 23 Jul 2026). */
  margin: 12px;
  padding: 16px;
  height: 420px;
  overflow-y: auto;
  font-family: "SF Mono", ui-monospace, Menlo, monospace;
  font-size: 13px;
  line-height: 1.6;
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: 12px;
}
.line-tx { color: var(--accent); }
.line-rx { color: var(--text); }
.line-err { color: var(--error); }
.line-info { color: var(--text-dim); }

.console-input-row {
  display: flex;
  gap: 10px;
  padding: 12px 16px;
  border-top: 1px solid var(--border);
}
.console-input-row .input-basic { flex: 1; }

.text-link { color: var(--accent); text-decoration: none; }

/* A link inside body copy reads WHITE, not blue (GK, 24 Aug 2026, of the
   About card's YouInControl.net: "The blue is hard to read"). The accent
   blue is the UI's ACTION colour and it was never meant to carry running
   text - at 15px on the dark ground it is the one thing on the page you
   have to squint at. Underlined so it is still obviously a link, and
   scoped to prose inside a card so no control, tab or map button that
   sets its own colour is touched. */
.form-card p a,
.footnote a { color: var(--text); text-decoration: underline; }
.form-card p a:hover,
.footnote a:hover { color: var(--text); text-decoration-thickness: 2px; }

/* --- Raw Console: full-window shell at every size (Decision 27) -------- */
/* The Raw Console fills the window at any width - the log is the only
   scrollable region, the command box is pinned to the bottom - so it
   never collapses into a short, narrow strip on a wider browser. Height
   binds to the visual viewport in JS so the box rides above the on-screen
   keyboard. */
html:has(body.raw-page) { overflow: hidden; }
body.raw-page {
  height: 100vh;    /* fallback before JS / without dvh */
  height: 100dvh;
  min-height: 0;    /* clear the app-wide min-height:100vh */
  overflow: hidden;
  display: flex;
  flex-direction: column;
}
body.raw-page .content {
  flex: 1;
  min-height: 0;
  /* width:100% is required: the base .content has margin:0 auto, and auto
     side-margins on a flex item suppress stretch - without an explicit
     width it collapses to its intrinsic width and centres, leaving the
     console narrow. */
  width: 100%;
  max-width: 960px;    /* same width as every other page (consistency) */
  padding: 16px 24px 20px;
  display: flex;
}
body.raw-page .console-page {
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
  gap: 14px;
}
body.raw-page .console-card {
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
}
body.raw-page .console-out { flex: 1; min-height: 0; height: auto; }

/* --- Actions page (Phase 3) ------------------------------------------- */

.actions-grid {
  display: grid;
  /* Cards are wide enough that the button row never wraps; min(...) keeps
     the grid from overflowing on screens narrower than a full card. */
  grid-template-columns: repeat(auto-fill, minmax(min(360px, 100%), 1fr));
  gap: 14px;
}
.action-card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-tile);
  padding: 16px;
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.action-card.disabled { opacity: 0.55; }
.action-head { display: flex; align-items: center; gap: 10px; }
.action-head h3 { font-size: 16px; margin: 0; flex: 1; }
.slot-badge {
  font-family: "SF Mono", ui-monospace, Menlo, monospace;
  font-size: 12px;
  color: var(--accent);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 2px 7px;
}
.action-desc { font-size: 13px; color: var(--text-secondary); margin: 0; }
.action-data {
  font-family: "SF Mono", ui-monospace, Menlo, monospace;
  font-size: 12px; color: var(--text-dim); margin: 0;
}
.action-notes { font-size: 12px; color: var(--text-dim); margin: 0; font-style: italic; }
.action-buttons { display: flex; gap: 8px; flex-wrap: nowrap; margin-top: 4px; }
/* Outcome LED per action card (Decision 25): lights on the result,
   holds a moment, then fades. The reason stays on hover (title). */
.action-led {
  display: inline-block;
  width: 9px;
  height: 9px;
  border-radius: 50%;
  background: transparent;
}
.action-led.led-ok {
  background: #3ecf6b;
  box-shadow: 0 0 10px rgba(62, 207, 107, 0.6);
  animation: led-fade 1.8s ease-out forwards;
}
.action-led.led-fail {
  background: var(--error);
  box-shadow: 0 0 10px rgba(224, 82, 82, 0.6);
  animation: led-fade 1.8s ease-out forwards;
}
.action-led.led-warn {
  background: #e8a13c;
  box-shadow: 0 0 10px rgba(232, 161, 60, 0.6);
  animation: led-fade 1.8s ease-out forwards;
}
@keyframes led-fade {
  0%, 56% { opacity: 1; } /* solid ~1 s of the 1.8 s run, then fade */
  100% { opacity: 0; }
}
.btn-pressed { animation: btn-press 0.25s ease-out; }
@keyframes btn-press {
  0% { transform: scale(1); }
  40% { transform: scale(0.93); filter: brightness(1.35); }
  100% { transform: scale(1); }
}

.form-card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-card);
  padding: 18px 20px;
  /* ONE section rhythm app-wide, tuned by GK's eye (26 -> 16, 21 Jul 2026):
     no page-local overrides. */
  margin-bottom: 16px;
}

/* ONE vertical rhythm INSIDE every card too (GK, 16 Aug 2026: "it is
   all over the shop. Boxes touching, no spaces between text and action
   buttons... app wide"): 16px between a card's blocks, at the ROOT, so
   every block a card ever grows inherits the spacing without anyone
   remembering. Written HERE, early, so the deliberate exceptions below
   in this file (fold heads staying tight, row families owning their own
   pitch, pinned notes) override it as the visible, explicit rules they
   are - an exception must be a rule you can read, never a rule that is
   missing. */
.form-card > * + * { margin-top: 16px; }
/* Paragraph-after-paragraph keeps the PROSE rhythm inside cards - two
   paragraphs are one flow, not two blocks. */
.form-card > p + p { margin-top: 10px; }
/* +2px (GK, 23 Jul 2026: 16 -> 18). The open-head air is set on
   .collapsible > .fold-head above (17px); this base rule sizes the h2. */
.form-card h2 { font-size: 20px; margin: 0 0 12px; }

/* Sign out sits alone at the very bottom of the home page (GK, 23 Jul
   2026) - out of the Password card, clear of the cards above. */
.page-signout { margin-top: 24px; display: flex; justify-content: center; }

/* The revealed recovery code - big, spaced, monospace, to read off and
   write down; INLINE to the right of the buttons (GK, 23 Jul 2026). */
.recovery-code {
  font-family: "SF Mono", ui-monospace, Menlo, Consolas, monospace;
  font-size: 24px;
  font-weight: 700;
  letter-spacing: 0.28em;
  color: var(--text);
  align-self: center;
}
.form-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: 12px 16px;
}
.form-grid label { font-size: 12px; color: var(--text-secondary);
  display: flex; flex-direction: column; gap: 5px; }
.form-grid .form-wide { grid-column: 1 / -1; }
.form-grid .form-check { flex-direction: row; align-items: center; }
.form-actions { display: flex; gap: 10px; align-items: center; margin-top: 16px; } /* aligned to the 16px in-card law, 16 Aug 2026 */
/* The Security card's Sign out sits apart from Set/Change password - it is
   a session action, not part of the password form. */
/* Sign out is the shared danger red (GK, 23 Jul 2026: "red, similar to
   output off") - the owner's button carries .btn-danger, matching
   Disconnect/Withdraw/Delete; the guest header link is .nav-logout. */
.form-error { color: var(--error); font-size: 13px; }
.form-ok { color: var(--accent); font-size: 13px; }

/* Systems list: one selectable row per You.InControl system, the active
   one marked with the accent left-border (matches the Devices directory). */
.system-list { display: flex; flex-direction: column; gap: var(--selection-gap); margin-bottom: 14px; }
/* A connection kind is a SELECTABLE ROW, exactly like a system row
   (GK, 16 Aug 2026: "They dont really look like significant selectable
   options... make more important, bold with a bit more space between
   the led and the words"). Same box, same 12px lamp-to-words gap, same
   blue left bar when chosen - the selectors are extended here rather
   than a second look invented, so the two can never drift apart. */
.system-row,
.conn-mode-choice {
  display: flex;
  align-items: center;
  gap: 12px;
  background: var(--surface-raised);
  /* No left bar (GK, 16 Aug 2026: "Not sure we need the blue line as
     well as the LED. Can you try without") - the lamp alone says which
     row is chosen, so the box wears one plain border like every other. */
  border: 1px solid var(--border);
  border-radius: var(--radius-tile);
  /* 12px, not 10 (GK, 16 Aug 2026: "add another 4 px to the bounding
     boxes height around them") - a selection row stands 45px, taller
     than a plain control, because it is a thing you choose, not a thing
     you fill in. Both families move together, as always. */
  padding: 12px 14px;
}
.system-row-main {
  display: flex;
  align-items: center;
  gap: 12px;
  flex: 1;
  min-width: 0;
  cursor: pointer;
}
.system-row-main input[type="radio"]:disabled { cursor: default; }
.system-row-name,
.conn-mode-choice {
  /* 17px, two above body (GK, 16 Aug 2026: "make the font for those
     boxes in question, 2 px bigger") - a deliberate exception to the
     one-size body law, and a readable one: these rows are things you
     CHOOSE, and they say so by standing taller and reading larger than
     the boxes you fill in. Both selection families share the rule. */
  font-size: 17px;
  font-weight: 600;
  color: var(--text);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.system-row.active .system-row-name { color: var(--text); }
/* When the selected row carries its editable name box (a SIBLING of the
   main label, so a click to edit does not toggle selection), the label
   holds only the lamp and must size to it - otherwise flex:1 gives the
   empty label half the row and the name box floats out to the centre
   (GK, 24 Aug 2026: "that does not look right"). The name box takes the
   rest, left-aligned by the lamp like every other row. */
.system-row-editing .system-row-main { flex: 0 0 auto; }
/* The SELECTED system's name is editable in place (GK, 24 Aug 2026) - it
   reads as the row's name until you touch it, then behaves like the field
   it is. A box drawn around it permanently would turn the selector into a
   form; the app's rule is that a box means editable, and here the hover
   and focus states carry that instead. */
.system-row-name-edit {
  flex: 1;
  min-width: 0;
  /* NO border and NO vertical padding, so the editable name occupies the
     exact same box as a plain name span - the selected (editable) row must
     be the same height as the others (GK, 24 Aug 2026: "Including in the
     Select system above - even if text can be changed"). A border would
     add 2px and vertical padding 8px, which is what made the active row
     58px against the others' 48px. The hover/focus affordances use
     background and outline instead - neither changes layout.
     margin-left cancels the 8px inset so the text sits exactly where an
     inactive name's does. */
  background: transparent;
  border: none;
  border-radius: 6px;
  /* 8px inset so the hover/focus box has breathing room; NO negative
     margin - the name then sits exactly where an inactive row's name
     does (dot + the row gap), measured aligned. */
  padding: 0 8px;
  /* Same size and weight as the other rows' names (17px/600, the
     selection-row exception) - not the lighter body font. */
  font-family: inherit;
  font-size: 17px;
  font-weight: 600;
  color: var(--text);
}
.system-row-name-edit:hover { background: rgba(255, 255, 255, 0.04); }
.system-row-name-edit:focus {
  background: var(--surface-raised);
  outline: 1px solid var(--accent);
  outline-offset: 0;
}

/* --- Setup diagnostics (Phase 4) --------------------------------------- */

/* Ping result is plain information below the buttons - not a bordered box
   that reads like a text input. */
.ping-result { margin-top: 14px; }
.ping-result h3 { font-size: 14px; color: var(--text); margin: 0 0 8px; }
.ping-result pre {
  font-family: "SF Mono", ui-monospace, Menlo, monospace;
  font-size: 12px;
  line-height: 1.6;
  color: var(--text-secondary);
  margin: 0;
  overflow-x: auto;
}

/* --- Phone layout (V1 feedback, 11 Jul 2026) --------------------------- */
/* Sound-mobile pass (Decision 27): >=44px touch targets (Apple HIG / WCAG
   2.5.5), 16px form fonts so iOS Safari never auto-zooms on focus,
   safe-area insets for the home indicator, and a fixed-height Raw
   Console whose only scrollable region is the log, command box pinned
   to the bottom. The keyboard resizes the layout, not the page, via the
   viewport meta (interactive-widget=resizes-content). */

/* --- Devices (Phase 6 - directory) ------------------------------------- */

/* Search + filter controls: 40px against the 50px rows (GK, 26 Aug 2026:
   "if the Devices are 50px, the Search etc. should be 40px high"). */
.search-box {
  display: block;
  width: 100%;
  height: 40px;
  font-size: 16px;
  margin-bottom: 12px;
}
/* Search box shares its row with the Show hidden toggle, pinned to the
   right edge. margin-left:auto keeps it right even when the search box is
   hidden (a dimension is active). */
/* ONE vertical rhythm for the directory (GK, 26 Aug 2026: "Why are the
   distances between boxes different?") - every box sits 8px above the
   next, the same gap the device rows keep between themselves. */
.search-row { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
/* height repeated here ON PURPOSE (GK, 26 Aug 2026: "All lines boxes and
   lines should really be the same height"): the shared input.input-basic
   control height out-specifies bare .search-box, so the box sat a
   different height from the select and buttons beside it. */
.search-row .search-box { flex: 1; min-width: 0; margin-bottom: 0; height: 40px; }
.search-row .toggle { margin-left: auto; white-space: nowrap; }
/* Every button on a search row matches the box beside it (40px), so they
   line up whatever the row holds - Devices has one, ExDev has two (GK,
   26 Jul 2026). Was an #refresh-devices ID override, which only dressed
   the one button it named and left the next one short. */
.search-row .btn { height: 40px; }
/* On a phone the row WRAPS: the search box takes a full line of its own and
   the controls (Show hidden / Refresh) form the line below - three items in
   one 375px line squeezed the box to nothing (26 Aug 2026). */
@media (max-width: 640px) {
  .search-row { flex-wrap: wrap; }
  .search-row .search-box { flex: 1 1 100%; }
}
/* The blue Add Device on its own row UNDER the search bar (GK, 26 Aug 2026:
   "Put the add device under the search bar"), sized like the row above it. */
.add-device-row { margin-bottom: 8px; }
.add-device-row .btn { height: 40px; }
/* The Add Device card's three steps (GK, 26 Aug 2026) - body text with the
   browser's numbering, just pulled in line with the card's other blocks. */
.pair-steps { margin: 0; padding-left: 22px; }
/* The directory's action-panel titles sit a notch under the app's 20px card
   titles (GK, 26 Aug 2026: "Why is Add Device such big text... Should all
   be the same") - one size across both pages' Add panels. */
#add-device-card h2, #add-panel h2, #add-panel h3 { font-size: 16px; }
.filter-bar {
  display: flex;
  gap: 12px;
  flex-wrap: wrap;
  align-items: center;
  margin-bottom: 8px;  /* the directory's one 8px rhythm */
}
.filter-bar .select {
  height: 40px;
  font-size: 15px;
  flex: 1 1 240px;
  /* The custom chevron LEFT here on 1 Sep 2026 - it is on .select now, so
     every select in the app has it and this copy would only be a second
     one to drift from. */
}
/* When a dimension is active the chips are the search mechanism and stand
   on their own - no enclosing box. */
.filter-values { margin-bottom: 18px; }
.value-chips { display: flex; flex-wrap: wrap; gap: 8px; }
/* Pills wear 10px everywhere (GK, 26 Aug 2026) - the app's control corner,
   not the full round. Same rule applied to every chip-shaped control below;
   the scrollbar thumb, the toggle knob and the progress bar stay round,
   being shapes rather than pills. */
.value-chip {
  background: var(--surface);
  color: var(--text-secondary);
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 7px 14px;
  font-size: 13px;
  cursor: pointer;
}
.value-chip:hover { background: var(--surface-raised); }
.value-chip.selected {
  background: var(--accent);
  border-color: var(--accent);
  color: #fff;
}
.group-heading {
  font-size: 12px;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--text-dim);
  margin: 20px 0 8px;
}
.device-row {
  display: flex;
  align-items: center;
  gap: 12px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-left: 3px solid var(--text-dim);
  border-radius: var(--radius-tile);
  /* A fixed 50px row (GK, 26 Aug 2026: "the Devices are 50px") - the flex
     centring places the content, so the vertical padding went with it. */
  height: 50px;
  padding: 0 14px;
  margin-bottom: 8px;
}
.device-row:hover { background: var(--surface-raised); }
/* ONE corner for the directory (GK, 26 Aug 2026: "Top ones look different
   to the bottom ones", tuned live 3px -> 8px -> "even 10px") - the filter,
   the search box, its buttons, Add Device and the rows all wear 10px, the
   app's standard control corner. Sits after .device-row so it wins that
   tie (rows were 14px via --radius-tile). */
.filter-bar .select, .search-row .search-box, .search-row .btn,
.add-device-row .btn, .device-row { border-radius: 10px; }
.device-row.hidden-device { opacity: 0.5; }
.device-row-main {
  display: flex;
  align-items: center;
  gap: 12px;
  flex: 1;
  min-width: 0;
  text-decoration: none;
  color: var(--text);
}
.device-row-name {
  font-size: 15px;
  font-weight: 500;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.device-row-main .type-badge { margin-left: auto; }
/* A row's tags read as ONE GROUP at the right-hand end (GK, 27 Jul 2026:
   "the TP-Link tag should be next to the ExDev#1"). Only the FIRST tag
   pushes right: flexbox splits the free space EQUALLY between every auto
   margin, so a second tag carrying its own auto put TP-Link and ExDev #1
   at opposite ends of the row (measured 312px apart). Each keeps its own
   box - they are separate tags, just adjacent. */
.device-row-main .disabled-badge ~ .disabled-badge,
.device-row-main .disabled-badge ~ .type-badge,
.device-row-main .type-badge ~ .type-badge { margin-left: 0; }
.type-badge {
  font-size: 11px;
  color: var(--text-secondary);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 2px 8px;
  white-space: nowrap;
}
/* In the listing, the type label keeps the height it shared with the old
   Hide button (rows removed theirs - GK, 26 Aug 2026). */
.device-row .type-badge { display: inline-flex; align-items: center; height: 28px; }
/* The Add-a-device panel's found list: the container owns the gap under
   "Choose the ones you want to control." (GK, 27 Jul 2026), same as every
   other window in the app - the paragraph above it carries no bottom margin
   of its own, by the one-body-text law. */
.found-list:not(:empty) { margin-top: 12px; }
/* A disabled device is clearly marked (and its name muted). */
.disabled-badge {
  flex: none;
  font-size: 11px;
  color: #e8a13c;
  background: rgba(232, 161, 60, 0.12);
  border: 1px solid rgba(232, 161, 60, 0.35);
  border-radius: 8px;
  padding: 2px 8px;
  margin-left: 10px;
  white-space: nowrap;
}
/* In the listing, the Disabled badge sits just left of the type badge:
   it takes the auto margin (pushing itself + the type badge to the right)
   and the type badge only gaps after it. */
.device-row-main .disabled-badge { margin-left: auto; }
.disabled-device .device-row-main .type-badge { margin-left: 0; }
/* GK (21 Jul 2026): a disabled device is greyed out properly in the
   listing - muted name and type on a darkened face, no colour accent
   (devices.js drops the edge colour too) - with the amber Disabled tag
   left the one bright thing saying why. */
.disabled-device { background: #101215; }
.disabled-device:hover { background: var(--surface); }
.disabled-device .device-row-name,
.disabled-device .device-row-main .type-badge { color: var(--text-dim); }

/* AI Context: the example is a faint hint (much dimmer than a real value);
   typed text reads clearly. */
#ai-context { color: var(--text); }
#ai-context::placeholder { color: var(--text-dim); opacity: 0.55; }

/* Name: a normal edit box (inherits .input-basic) so it clearly reads as
   editable; value shown bold white, placeholder is the type when unnamed. */
.name-title { font-size: 20px; font-weight: 700; color: #fff; }
.name-title::placeholder { color: var(--text-secondary); font-weight: 400; }

/* Read-only identity row: type badge + "Device N", with a Disabled badge
   pushed to the top-right when the device is disabled. */
.device-meta { display: flex; align-items: center; gap: 10px; }
.device-meta .type-badge { font-size: 13px; padding: 4px 12px; }
/* Match the type badge's height and text size; keep the amber colours. */
.device-meta .disabled-badge { margin-left: auto; font-size: 13px; padding: 4px 12px; }
.device-ident { font-size: 13px; color: var(--text-dim); }
/* Device enable/disable switch, top-right of the card (GK): the state is spelled
   out beside it - amber when disabled, carrying over the old badge's meaning. */
.dev-enable { display: flex; align-items: center; gap: 10px; margin-left: auto; }
.dev-enable-text { font-size: 13px; color: var(--text-secondary); white-space: nowrap; }
.dev-enable-text.is-disabled { color: #e8a13c; }
/* A little extra breathing room below the name field. */
.device-edit .form-card .name-field { margin-bottom: 8px; }

/* The two device toggles share one row, each sized to its own text so the
   click target does not span the card width (a Save tap can no longer land
   on Enabled). Extra 15px below separates them from Save/Cancel. */
.toggle-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 24px;
  margin-bottom: 15px;
}

/* Interaction devices (e.g. a Remote): a digital-twin press per channel,
   stacked vertically with clear separation from the identity row above. */
.interaction-block {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 8px;
  margin-top: 14px;
}
/* On a mixed device (outputs + inputs, e.g. Relay-1) tighten the gap between
   the output list and the inputs by 25px. Scoped via the adjacent out-msg so
   pure-interaction devices (Remote/Switch/UniSwitch) are unaffected. */
#out-msg + .interaction-block { margin-top: -11px; }
.interaction-buttons { display: flex; flex-direction: column; gap: 24px; }
.interaction-buttons .btn { min-width: 160px; }
/* Six gesture buttons (S/SS/SSS/L/SL/SSL) per interaction channel, on one row
   with its channel label - a mimic-press of the physical device's input. */
.interaction-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.interaction-label { min-width: 120px; font-weight: 500; }
.gesture-buttons { display: flex; gap: 6px; flex-wrap: wrap; }
.gesture-buttons .interaction-btn { min-width: 46px; padding: 6px 10px; }
/* Reserve every press gesture (single/double/triple/long) for our own
   handlers later: kill text selection, the iOS long-press callout, and
   the double-tap-to-zoom / tap delay. The context menu is cancelled in JS
   (device.js). Cross-browser: -webkit-* cover Safari/iOS, user-select is
   standard, touch-action:manipulation is honoured by all modern engines. */
.interaction-btn {
  -webkit-user-select: none;
  -moz-user-select: none;
  -ms-user-select: none;
  user-select: none;
  -webkit-touch-callout: none;
  touch-action: manipulation;
}

/* Output control (Relay-1/8, 240V Hub/Outlet): one compact row per channel -
   number, editable name, then Off / state-toggle / On. The colours cue the
   available action (Off red when on, On blue when off); the toggle mirrors
   the reflected state. Short-press switches; long-press On opens a timed
   run. State is driven by data-on on the row so JS only flips one flag. */
.output-list { display: flex; flex-direction: column; gap: 18px; }
/* The row stacks its control line and (when long-pressed) the timed-on
   popup; the line itself is the horizontal control cluster. */
.output-row { display: flex; flex-direction: column; }
.output-line { display: flex; align-items: center; gap: 8px; }
.out-num {
  font-family: "SF Mono", ui-monospace, Menlo, monospace;
  font-size: 13px; color: var(--text-dim); min-width: 16px; text-align: center;
}
.out-name { flex: 1; min-width: 0; height: 34px; font-weight: 500; }
.out-off, .out-on {
  height: 34px; border-radius: 8px; padding: 0 22px; flex: none;
  font-size: 14px; font-weight: 500; cursor: pointer;
  background: transparent; color: var(--text-secondary); border: 1px solid var(--border);
  -webkit-user-select: none; user-select: none; -webkit-touch-callout: none;
  touch-action: manipulation;
}
/* â­ Off is DULL RED and On is DULL BLUE at all times (GK, 10 Aug 2026).
   The colour is the button's IDENTITY, not its state: before this the
   pressable one lit up and the dead one went grey, so the pair announced the
   state as well - and the indicator between them was already saying it. One
   thing says one thing. */
.out-off { background: #7e3535; border-color: #944444; color: #f2e6e6; }
.out-on { background: #2f4a86; border-color: #3d5c9f; color: #e6ecf7; }
.out-off:hover { background: #8e3c3c; }
.out-on:hover { background: #365396; }

/* The indicator is the SWITCH (GK, 10 Aug 2026: "pressing the on off
   indicator toggles it") - so it is a real button, not the decoration it
   used to be. */
.out-toggle {
  display: inline-flex; flex: none; width: 56px; height: 34px; margin: 0 5px;
  border-radius: 999px; position: relative; padding: 0;
  background: #20242b; border: 1px solid #3a404b;
  cursor: pointer; font: inherit;
  -webkit-user-select: none; user-select: none; -webkit-touch-callout: none;
  /* A hold on a phone must be a LONG PRESS, not the text-selection callout -
     the same lesson the VC page's buttons carry. Without this the timer
     gesture is simply unreachable on a phone. */
  touch-action: manipulation;
}
.out-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.out-knob {
  position: absolute; top: 3px; left: 3px; width: 26px; height: 26px;
  border-radius: 50%; background: var(--text-dim); transition: left .15s, background .15s;
}
/* The ON toggle is the sensor readout orange (GK, 23 Jul 2026) - #ffb641,
   the same colour the temperature reads in; one value, owner + guest. It is
   now the ONLY thing on the row that moves with the state (GK, 10 Aug 2026),
   which is what makes it readable at a glance. */
.output-row[data-on="true"] .out-toggle { background: rgba(255, 182, 65, 0.16); border-color: var(--readout); }
.output-row[data-on="true"] .out-knob { left: 27px; background: var(--readout); }
.output-row.busy { opacity: 0.55; }

/* Automations as rows (like Devices / Outputs): name + context cog + Trigger. */
/* Automations use the Devices-list listing style (GK): a bordered card with a
   blue accent bar, an instantly-editable name, a settings cog and a Trigger.
   The details panel tucks flush under the card when the cog is open. */
.automation-list { display: flex; flex-direction: column; gap: 10px; }
.automation-row { display: flex; flex-direction: column; }
.automation-line {
  display: flex; align-items: center; gap: 12px;
  background: var(--surface); border: 1px solid var(--border);
  border-left: 3px solid var(--accent);
  border-radius: var(--radius-tile); padding: 12px 14px;
}
.automation-line:hover { background: var(--surface-raised); }
.auto-badge { flex: none; font-family: "SF Mono", ui-monospace, Menlo, monospace; font-size: 13px; font-weight: 600; color: var(--accent); min-width: 18px; text-align: center; }
/* Reads like the Devices-list name, but edits instantly (GK's instant rename). */
.auto-name { flex: 1; min-width: 0; height: 34px; font-size: 15px; font-weight: 500; background: transparent; border: 1px solid transparent; border-radius: 8px; padding: 0 8px; }
.auto-name:hover { border-color: var(--border); }
.auto-name:focus { background: var(--surface-raised); border-color: var(--accent); }
.automation-line .btn { height: 34px; padding: 0 22px; }
/* Cog open: the details panel tucks flush under the card, and the blue accent
   bar runs the FULL height - card and panel as one bordered area (GK, 21 Jul
   2026: "the Blue left border should encompass the whole area"). */
.automation-row.open .automation-line { border-radius: var(--radius-tile) var(--radius-tile) 0 0; }
.automation-row.open .out-settings {
  margin-top: 0; border-top: none;
  border-radius: 0 0 var(--radius-tile) var(--radius-tile);
  border-left: 3px solid var(--accent);
}
/* A disabled automation reads muted in the list, exactly like a disabled
   device: the name loses its weight, the number its accent. The Trigger stays
   - the Controller refuses it honestly if it is truly off. */
.automation-row.is-disabled .auto-name { color: var(--text-dim); }
.automation-row.is-disabled .auto-badge { color: var(--text-dim); }

/* Input rows (Remote / Switch / UniSwitch / Relay-1's two). Deliberately the
   same row as an output - number, editable name, cog - with the Off / state /
   On cluster replaced by the six press combos, so the two read as one system.
   A combo is grey until the Controller says that gesture has an action list
   bound to it (60), then it is the same solid accent the On button uses: the
   row tells you at a glance which presses actually do something. */
.input-list { display: flex; flex-direction: column; gap: 18px; }
.input-row .output-line { flex-wrap: wrap; }
/* Without a floor the name box collapses to nothing: .out-name is flex:1, so it
   gives up all its width to the combos before they ever wrap. */
.input-row .out-name { min-width: 150px; }
.in-combos { display: flex; gap: 6px; flex: none; flex-wrap: wrap; margin-left: auto; }
/* When the combos wrap under the name (a phone, or many inputs), the wrapped
   line starts at the name box's left edge: the number's 16px + the 8px gap.
   device.js measures the wrap per row (width alone cannot say) and sets the
   class; basis + margin = 100% keeps it on its own line without overflow. */
.in-combos.wrapped { flex-basis: calc(100% - 24px); margin-left: 24px; }
/* An input the Controller has not set up (never activated - the 540 read
   skips it): no press buttons, a neutral "Not active" tag instead (GK).
   Neutral grey, not the amber of Disabled - Disabled is an owner's lock;
   this is simply "nothing here yet". */
.input-row.in-inactive .in-combos { display: none; }
.input-row.in-inactive .out-name { color: var(--text-secondary); }
.out-disabled-tag.tag-inactive {
  color: var(--text-dim);
  background: rgba(160, 160, 160, 0.10);
  border-color: rgba(160, 160, 160, 0.30);
}
.in-combo {
  height: 34px; min-width: 44px; padding: 0 10px; border-radius: 8px;
  font-size: 14px; font-weight: 500; cursor: pointer;
  background: transparent; color: var(--text-secondary); border: 1px solid var(--border);
  -webkit-user-select: none; -moz-user-select: none; user-select: none;
  -webkit-touch-callout: none; touch-action: manipulation;
}
.in-combo[data-bound="true"] {
  background: var(--accent); border-color: var(--accent); color: #fff;
}
/* A disabled input is flagged the way a disabled output is: muted name. */
.input-row.in-disabled .out-name { color: var(--text-secondary); }

/* Per-output settings cog + its dropdown (context now, more later). */
/* No box around the cog (GK) - just the symbol; hover lifts its colour. */
.out-cog {
  flex: none; width: 34px; height: 34px; display: inline-flex;
  align-items: center; justify-content: center; border-radius: 8px;
  background: transparent; color: var(--text-dim); border: none; padding: 0;
  cursor: pointer; transition: color .15s;
}
.out-cog:hover { color: var(--text-secondary); }
.out-settings { margin-top: 10px; background: #141619; border: 1px solid var(--border); border-radius: 12px; padding: 14px; }
/* The Details field fills the box width, styled like the AI Setup boxes. */
.out-details-lab { display: block; font-size: 12px; color: var(--text-dim); margin-bottom: 6px; }
.out-settings .out-details { width: 100%; box-sizing: border-box; resize: vertical; min-height: 84px; font-family: inherit; line-height: 1.5; }
/* Per-output Enabled lock: a compact on/off switch (smaller than the main
   output toggle), press-on / press-off. State is app-side best-effort until the
   Controller reports Output_Enabled on the 41 read (firmware follow-up). */
.out-en-row { display: flex; align-items: center; gap: 10px; margin-top: 12px; }
/* Settings-panel header: title on the left, the Enabled lock top-right
   (GK, 20 Jul 2026). Inside it the row loses its below-the-box margin. */
/* Button Setup (design doc 22) - the input cog panel's binding editor.
   Chips and picker rows are the composer's own classes; only the frame
   and the step sentences are new. */
/* One column, title-then-details (GK, 26 Aug 2026: "dont want two
   columns... Make it a title and then the details"). The section titles
   all wear .out-pop-title, so their font is one decision, not three.
   RHYTHM (GK: "Make the spacing a little nicer"): 20px between sections,
   10px under a title, 8px between list rows - one scale, evenly. */
.ib-box { margin-top: 20px; }
.ib-type-row { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 20px; }
.ib-slot { margin: 0 0 20px; display: flex; flex-direction: column; gap: 8px; align-items: flex-start; }
.ib-slot:last-child { margin-bottom: 4px; }
/* The slot titles wear .out-pop-title; its 12px bottom margin trims to
   2px here - with the slot's 8px gap that is the 10px under-title air. */
.ib-slot .ib-slot-name { margin-bottom: 2px; }
.ib-step { display: flex; align-items: center; gap: 10px; }
/* The listed actions: numbered, number and text in ONE colour (GK, 26 Aug
   2026: "Not blue numbers. just the same colour"), at the titles' 14px but
   never bold. */
.ib-step-n { font-size: 14px; flex: none; }
.ib-step-text { font-size: 14px; font-weight: 400; }
.ib-cut { flex: none; }
.ib-picker { margin: 4px 0; }
.ib-secs { width: 90px; height: 32px; }

.out-pop-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 10px; }
.out-pop-head .out-pop-title { margin-bottom: 0; }
/* The panel-head note ("Set up on Controller" on a Not-active input): the
   title's size and line so the head reads as one level, muted colour. */
.out-pop-head .out-pop-note { font-size: 13px; font-weight: 500; color: var(--text-dim); }
.out-pop-head .out-en-row { margin-top: 0; }
.out-en-toggle { position: relative; flex: none; width: 38px; height: 20px; padding: 0; border-radius: 10px; border: 1px solid var(--border); background: var(--surface-raised); cursor: pointer; transition: background .15s, border-color .15s; }
.out-en-toggle .out-en-knob { position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; border-radius: 50%; background: var(--text-dim); transition: left .15s, background .15s; }
.out-en-toggle[data-on="true"] { background: var(--accent); border-color: var(--accent); }
.out-en-toggle[data-on="true"] .out-en-knob { left: 20px; background: #fff; }
/* An output whose Enabled lock is off is flagged in the list (name muted + tag). */
.output-row.out-disabled .out-name { color: var(--text-secondary); }
/* A disabled output's controls are REMOVED and the Disabled tag stands in
   their place (GK, 20 Jul 2026): the greyed controls plus the tag together
   crushed the flexed name to nothing on a phone. device.js also refuses the
   clicks (belt and braces while the row re-dresses). The cog stays - it is
   the way back to re-enable. */
.output-row.out-disabled .out-off,
.output-row.out-disabled .out-toggle,
.output-row.out-disabled .out-on { display: none; }
/* Same rule for a disabled input's six press combos. */
.input-row.in-disabled .in-combos { display: none; }
.out-disabled-tag, .share-unread { flex: none; font-size: 11px; color: #e8a13c; background: rgba(232, 161, 60, 0.12); border: 1px solid rgba(232, 161, 60, 0.35); border-radius: 8px; padding: 2px 8px; white-space: nowrap; }

/* Timed-on popup (long-press On): one input method per row. */
.out-pop { margin-top: 10px; background: #141619; border: 1px solid var(--border); border-radius: 12px; padding: 14px; }
/* GK-tuned on the glass, 26 Aug 2026 (13/500 -> 15/400 -> 14/700): one
   rule, so Input Details, Input Function and Actions move together,
   everywhere the class runs. */
.out-pop-title { font-size: 14px; font-weight: 700; margin-bottom: 12px; color: var(--text); }  /* the accordion headings' own colour (GK) */
.out-pop-row { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
.out-pop-lab { font-size: 12px; color: var(--text-dim); width: 52px; flex: none; }
.out-chip { font-size: 13px; color: var(--text-secondary); background: var(--surface-raised); border: 1px solid var(--border); border-radius: 10px; padding: 6px 12px; cursor: pointer; transition: background-color .35s, color .35s, border-color .35s; }
/* A momentary press confirmation: goes blue, then eases back to grey (the JS
   removes .flash after ~0.9s; the transition does the fade). Not a sticky state. */
.out-chip.flash { color: #fff; background: var(--accent); border-color: var(--accent); }
.out-units { display: flex; flex-wrap: wrap; gap: 16px; }
.out-unit { display: flex; align-items: center; gap: 5px; }
.out-unit .unit-label { font-size: 13px; color: var(--text-dim); width: 12px; text-align: center; }
.out-stp { width: 30px; height: 32px; flex: none; color: var(--text-secondary); background: var(--surface-raised); border: 1px solid var(--border); border-radius: 8px; cursor: pointer; font-size: 16px; line-height: 1; }
/* Fixed two-digit width - must out-specify .device-edit .form-card
   .input-basic { width: 100% }. */
.device-edit .form-card .out-units .out-num-in {
  width: 40px; flex: none; height: 32px; text-align: center; padding: 0 4px;
}
.out-until { width: 132px; height: 32px; }

/* The edit form stacks its fields cleanly: each label's text sits above a
   full-width control on its own row. */
/* DIRECT child only: the page's one wrapper card is a 16px-gap flex column,
   but the accordion cards nested inside it (Zones, Device Details) must NOT
   inherit that - a descendant match turned them into gap-16 columns and made
   their internal spacing unlike every other zipping section (GK). */
.device-edit > .form-card {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: 16px;
}
/* Nested accordion cards: the wrapper's gap IS their rhythm - their own
   card margins would double it. */
.device-edit > .form-card .form-card { margin: 0; }
.device-edit .form-card label {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 6px;
  font-size: 13px;
  color: var(--text-secondary);
}
.device-edit .form-card .input-basic { width: 100%; }
/* GK (20 Jul 2026): ONE section gap - 36px - between every section of the
   device page, taken from the Relay-8 last-output -> Device Details gap.
   The card is a 16px-gap flex column, so each section boundary carries
   20px of margin: 16 + 20 = 36. Empty flash paragraphs must not hold
   space (they sat in the rhythm and stretched input pages to 78px). */
#out-msg:empty,
#press-msg:empty,
#sensor-msg:empty { display: none; }
/* GK (21 Jul 2026): "Device Details" reads as a section title - the h2
   treatment, like the Output Details panel's title - and its box uses the
   page font (a bare textarea falls back to the browser's monospace). */
.device-edit .form-card .device-details {
  display: flex; flex-direction: column; align-items: stretch;
}
/* Box and title come from the shared .collapsible / .fold-head rule, which
   also turns this <label> back into a row (the page styles labels as flex
   columns, which would drop the chevron under the text). */
.device-edit .form-card .device-details textarea {
  font-family: inherit; line-height: 1.5;
}
.device-edit .form-card .field-block { margin: 20px 0; }
.device-edit .form-card .toggle-row { margin-bottom: 0; }
.device-edit .form-card .form-actions { margin-top: 20px; }
/* The outputs->inputs gap on a mixed device (Relay-1) equals the 18px
   between rows (GK item 5, 20 Jul 2026). */
.output-list ~ .input-list { margin-top: 2px; }
.device-edit .form-card label.form-check {
  flex-direction: row;
  align-items: center;
  gap: 8px;
}
.field-block { margin: 4px 0 14px; }
.field-label {
  display: block;
  font-size: 13px;
  color: var(--text-secondary);
  margin-bottom: 8px;
}
.swatches { display: flex; gap: 10px; flex-wrap: wrap; }
.swatch {
  width: 30px;
  height: 30px;
  border-radius: 50%;
  border: 2px solid var(--border);
  cursor: pointer;
  padding: 0;
}
.swatch.selected {
  border-color: var(--text);
  box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--text);
}
.swatch-none {
  background:
    linear-gradient(45deg, transparent 45%, var(--text-dim) 45%,
    var(--text-dim) 55%, transparent 55%),
    var(--surface);
}

@media (max-width: 640px) {
  /* Every element edge is verified to fit the viewport; clip so nothing
     (including emulated-scrollbar rounding) can pan the page sideways. */
  html, body { overflow-x: clip; }
  .filter-bar { gap: 8px; }
  .filter-bar .select, .filter-bar .input-basic { flex: 1 1 100%; }
  .topbar {
    gap: 12px;
    padding: 12px 14px;
    padding-top: calc(12px + env(safe-area-inset-top));
  }
  .brand-logo { height: 52px; }
  .brand-word { font-size: 21px; }
  /* ⭐ ONE ROW, SWIPED (GK, 24 Aug 2026: "Top Menu. Need a better way to
     display all the items"). MEASURED before and after on a 375x812
     phone: the header was 209px - 26% of the screen - with the menu
     wrapping onto two rows at 90px. One scrolling row halves the menu and
     gives the page back most of that.

     Why a swipe strip and not a hamburger: a hamburger costs a tap before
     you can even SEE where you might go, on an app whose whole point is
     reaching a control quickly. Here the neighbours of wherever you are
     stay visible, and the strip scrolls the current page into view on
     load (status.js), so you always see where you are and what is either
     side of it. The fade at the right edge is what says there is more -
     without it a cut-off strip just looks like a short menu.

     ⬜ If the item count keeps growing this stops scaling and a real
     drawer becomes the honest answer. Ten fits; twenty will not. */
  .nav {
    gap: 18px;
    margin-bottom: 5px;  /* 5px extra gap under the menu */
    flex-wrap: nowrap;
    overflow-x: auto;
    -webkit-overflow-scrolling: touch;  /* momentum on iOS */
    /* ⚠ NO scroll-snap. It was tried and taken out: snapping to a label's
       START fights the scrollIntoView that CENTRES the current page, so
       the strip could shove the active tab back off-centre the moment it
       had been brought into view. A menu does not need to snap. */
  }
  /* THE FADE IS HONEST AT BOTH ENDS (status.js sets these). A permanent
     right-hand fade says "there is more this way" even when the strip is
     fully swiped and there is not; and a hard left edge hides that
     anything was scrolled past. So each side fades only when there really
     is something beyond it. */
  .nav[data-more-right="1"] {
    -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 24px), transparent);
    mask-image: linear-gradient(to right, #000 calc(100% - 24px), transparent);
  }
  .nav[data-more-left="1"] {
    -webkit-mask-image: linear-gradient(to left, #000 calc(100% - 24px), transparent);
    mask-image: linear-gradient(to left, #000 calc(100% - 24px), transparent);
  }
  .nav[data-more-left="1"][data-more-right="1"] {
    -webkit-mask-image: linear-gradient(to right, transparent, #000 24px,
                                        #000 calc(100% - 24px), transparent);
    mask-image: linear-gradient(to right, transparent, #000 24px,
                                #000 calc(100% - 24px), transparent);
  }
  /* The scrollbar goes only where a FINGER is the pointer. A desktop
     browser dragged narrow enough to hit this query has a mouse and no
     swipe, so taking its scrollbar away would leave shift+wheel as the
     only way to reach the tabs it cannot see. */
  @media (hover: none) and (pointer: coarse) {
    .nav { scrollbar-width: none; }
    .nav::-webkit-scrollbar { display: none; }
  }
  .nav-link {
    padding: 8px 0;  /* thumb-sized tap target */
    flex: none;      /* labels keep their width - never squeezed to fit */
  }
  /* The last item needs room to clear the fade, or it can never be read
     in full however far the strip is swiped. */
  .nav-link:last-child { padding-right: 24px; }
  /* 5px less each side on a phone (GK): tightens the gap left of the row
     number and right of the On button, giving the row ~10px more width. */
  .content { padding: 18px 9px; }
  /* And 5px off each side inside the card itself (20px -> 15px), same intent:
     the gap left of the row number and right of the On button. */
  .form-card { padding-left: 15px; padding-right: 15px; }
  /* One column on a phone: the desktop track min (min(360px, 100%))
     resolves circularly at widths below 360px and overshoots by ~2px,
     which lets the whole page pan sideways. */
  .actions-grid { grid-template-columns: 1fr; }
  .connect-controls { flex-wrap: wrap; }
  /* A select's minimum width follows its longest option (e.g. a long
     serial-port path) and would force the whole page wider than the
     screen - clamp it and let the option text truncate instead. */
  .connect-controls .select {
    flex-basis: 100%;
    width: 100%;
    min-width: 0;
    max-width: 100%;
  }

  /* Touch targets and zoom-proof fonts (Apple HIG 44px; iOS zooms any
     focused field under 16px). */
  .btn, .btn-small, .select, .input-basic, .range { min-height: 44px; }
  .input-basic, .select { font-size: 16px; }
  .btn-small { font-size: 13px; padding: 8px 12px; }
  /* The listing type label keeps its taller mobile touch height. */
  .device-row .type-badge { height: 44px; }

  .console-toolbar { flex-wrap: wrap; gap: 10px 12px; }
  /* Long log lines wrap on a phone; side-scrolling a console is worse. */
  .console-out {
    height: 320px;
    font-size: 12px;
    white-space: pre-wrap;
    overflow-wrap: anywhere;
  }
  /* Raw Console phone overrides (the full-window shell itself is defined
     once for all sizes, above). Full-bleed padding with extra bottom
     clearance for the keyboard / gesture bar - max() guarantees a solid
     gap even where the browser reports no safe-area inset (Chrome on
     Android often reports 0), which had left the box obscured on a
     Pixel 5. Live settings belong to Setup on a phone. */
  body.raw-page .content {
    max-width: none;
    padding: 12px 14px max(30px, calc(14px + env(safe-area-inset-bottom)));
  }
  body.raw-page .console-page { gap: 10px; }
  body.raw-page .page-head { margin-bottom: 0; }
  body.raw-page .page-head h1 { font-size: 20px; }

  /* Output row controls are each ~10px narrower on a phone (GK), giving the
     output name back ~30px. Off/On lose 5px of padding a side; the switch
     loses 10px of width, so the knob's "on" position moves in by the same
     10px to stay flush against its right edge. */
  .out-off, .out-on { padding: 0 17px; }
  .out-toggle { width: 46px; }
  .output-row[data-on="true"] .out-knob { left: 17px; }
}

/* --- Chat page (doc 10, Phase C1) -------------------------------------- */
/* Same full-window shell as the Raw Console: only the transcript scrolls,
   the input bar is pinned to the bottom (and rides above the on-screen
   keyboard via the JS viewport sync). */
html:has(body.chat-page) { overflow: hidden; }
body.chat-page {
  height: 100vh;    /* fallback before JS / without dvh */
  height: 100dvh;
  min-height: 0;    /* clear the app-wide min-height:100vh */
  overflow: hidden;
  display: flex;
  flex-direction: column;
}
body.chat-page .content {
  flex: 1;
  min-height: 0;
  width: 100%;         /* required: auto side-margins suppress flex stretch */
  max-width: 960px;    /* same width as every other page */
  padding: 16px 24px 20px;
  display: flex;
}
body.chat-page .chat-shell {
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
  gap: 14px;
}
.chat-card {
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-card);
  overflow: hidden;
}
.chat-toolbar {
  display: flex;
  align-items: center;
  gap: 14px;
  /* The bar wears a card head's exact rhythm (GK, 13 Aug 2026: same top
     spacing on every page): 18px above the heading, 12px under it, 20px
     sides - so "Chat" sits on the same line as "Notices" or "Camera". */
  padding: 18px 20px 12px;
  border-bottom: 1px solid var(--border);
}
.chat-toolbar .btn-small { margin-left: auto; }  /* push Setup + Clear right */

.chat-log {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  padding: 16px;
  display: flex;
  flex-direction: column;
  gap: 10px;
  background: var(--bg);
  /* An inset, bordered transcript frame - the same look as the guest
     Messaging box, so the three chat surfaces read alike (GK, 23 Jul). */
  margin: 12px;
  border: 1px solid var(--border);
  border-radius: 12px;
}

/* A message row aligns its bubble left (assistant) or right (owner). */
.chat-row { display: flex; }
.chat-row.from-user { justify-content: flex-end; }
.chat-row.from-bot { justify-content: flex-start; }
.chat-bubble {
  max-width: 78%;
  padding: 10px 13px;
  border-radius: 16px;
  background: var(--surface-raised);
  border: 1px solid var(--border);
}
.chat-row.from-user .chat-bubble {
  background: var(--accent);
  border-color: var(--accent);
}
.chat-text { font-size: 15px; line-height: 1.45; white-space: pre-wrap; }
.chat-meta {
  font-size: 11px;
  color: var(--text-dim);
  margin-top: 4px;
  text-align: right;
}
.chat-row.from-user .chat-meta { color: rgba(255, 255, 255, 0.7); }
/* Who answered - "App" or "LLM" - just before the time (GK, 11 Aug 2026).
   Quieter than the time itself: it is provenance, not content, and most turns
   on this system are answered by the app without a model at all. */
.chat-source {
  margin-right: 6px;
  padding: 1px 5px;
  border-radius: 3px;
  font-size: 10px;
  letter-spacing: 0.02em;
  background: rgba(255, 255, 255, 0.08);
}

/* An action-outcome card: a full-width strip with a live LED, so the owner
   sees what the assistant actually did and whether the Controller confirmed
   it (the same LED vocabulary as the Actions page). */
.chat-outcome {
  display: flex;
  align-items: center;
  gap: 10px;
  align-self: stretch;
  padding: 8px 12px;
  border: 1px solid var(--border);
  border-radius: 12px;
  background: var(--surface);
  font-size: 13px;
  color: var(--text-secondary);
}
.chat-outcome-text { flex: 1; }

/* A confirm prompt for an owner-marked dangerous action. */
.chat-confirm {
  align-self: stretch;
  padding: 12px;
  border: 1px solid var(--accent);
  border-radius: 12px;
  background: var(--surface);
}
.chat-confirm-buttons { display: flex; gap: 8px; margin-top: 10px; }

.chat-input-row {
  display: flex;
  gap: 10px;
  padding: 12px 16px;
  border-top: 1px solid var(--border);
}
.chat-input-row .input-basic { flex: 1; }

@media (max-width: 640px) {
  body.chat-page .content {
    max-width: none;
    padding: 12px 14px max(30px, calc(14px + env(safe-area-inset-bottom)));
  }
  body.chat-page .chat-shell { gap: 10px; }
  .chat-bubble { max-width: 88%; }
}

/* --- Chat toolbar: Setup pushed right, Clear an even gap beside it (GK,
   21 Jul 2026: "nicely spaced" - one 14px toolbar gap does the spacing, no
   extra per-button margins). ------------------------------------------- */
.chat-toolbar a.btn-small { margin-left: auto; }
.chat-toolbar button.btn-small { margin-left: 0; }

/* --- LLM readiness + the context figure (GK, 10 + 11 Aug 2026) ----------- */
/* Deliberately quiet at rest - it is a readout, not an alert - and it only
   takes a colour when the window is actually filling up. Which of the states
   is showing (LLM Not Ready / LLM Ready / a measured figure) is decided in
   chat.js, which owns the honesty.
   âš  The BAR was removed 11 Aug 2026 - GK: "Context used: 67%, no line" - and
   its rules deleted rather than parked. A rejected feature left in the sheet
   reads as a plan of record (the same call as migration 0052). */
.chat-context {
  display: flex;
  align-items: center;
  gap: 8px;
  min-width: 0;
}
.chat-context-text {
  font-size: 12px;
  color: var(--text-secondary);
  white-space: nowrap;
}
/* Filling up: the readout orange, the same "pay attention" colour the sensor
   pages use. Nearly full: the error red, matching a fail LED. */
.chat-context.is-warn .chat-context-text { color: var(--readout); }
.chat-context.is-full .chat-context-text { color: var(--error); }
/* No model at all - dimmed, so "LLM Not Ready" states a fact without shouting. */
.chat-context.is-idle .chat-context-text { color: var(--text-dim); }

/* The loaded-state words (GK, 11 Aug 2026: "LLM Loaded, or LLM Not Loaded"
   - green / red, "exactly the same on the chat page"). ONE pair, shared by
   the AI Model card header and the Chat readiness box - never a copy. The
   green is the stage-LED green as text: it means a brain is in memory.
   These outrank the box's own state colours (is-idle dims the asleep box;
   the WORD still reads red). */
.llm-state { font-size: 13px; font-weight: 600; margin-left: 10px; }
.llm-state-loaded { color: var(--llm-loaded) !important; }
.llm-state-notloaded { color: var(--error) !important; }

/* --- AI Setup page (doc 10) -------------------------------------------- */
/* The About card's body (GK, 21 Jul 2026 - the loose intro became a
   section like the rest). */
.ai-about-text {
 
 
  line-height: 1.5;
  margin: 0;
  max-width: 640px;
}
/* .ai-field-head is retired (GK, 21 Jul 2026): the prose cards' h2 is now
   the head itself, exactly like every other zipping section, so there is no
   wrapper left to style differently. */
.ai-badge {
  font-size: 11px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  padding: 2px 8px;
  border-radius: 6px;
  border: 1px solid var(--border);
}
/* The model card's controls fill their field block, like .ai-textarea above -
   an input left at its intrinsic width sat beside the select and truncated
   its own placeholder (GK, 10 Aug 2026). */
/* A field's guidance, above its box (GK, 10 Aug 2026). pre-line so a numbered
   list written as one string reads as a list, without the markup having to
   know whether a given field's help happens to contain one. */
.ai-field-help { white-space: pre-line; } /* own mb dropped - aligned to the 16px in-card law, 16 Aug 2026 */
/* A textarea is block, app-wide - inline it trails ~4px of baseline
   descender below itself, which read as a 20px gap against the 16px
   law (found by the 16 Aug 2026 spacing audit). */
textarea { display: block; }
.ai-model-control { width: 100%; }
/* The master switch row (GK, 10 Aug 2026) - the standard .output-row switch,
   sitting above the cards rather than inside one, with the card gap below it
   so the page rhythm is unbroken. */
/* .dev-enable carries margin-left:auto, so the switch sits hard right of the
   label exactly as it does on a device page. */
.ai-master-row {
  display: flex;
  align-items: center;
  margin-bottom: 16px;
  padding: 0 4px;
}
.ai-master-label { font-size: 15px; color: var(--text); }
.ai-default-note { font-size: 13px; color: var(--text-dim); align-self: center; }
.ai-badge.custom { color: var(--accent); border-color: var(--accent); }
.ai-textarea {
  width: 100%;
  resize: vertical;
  min-height: 110px;
  font-family: inherit;
  line-height: 1.5;
}
.ai-field-buttons {
  display: flex;
  align-items: center;
  gap: 10px;
  /* aligned to the 16px in-card law, 16 Aug 2026 */
  margin-top: 16px;
}
.ai-status { font-size: 13px; color: var(--text-dim); }

/* --- Sensors: temperature dial + water vessel -------------------------- */
/* A sensor is read, never driven, so it gets a reading surface rather than a
   control row: the number large enough to read across a shed, what it means
   (per cent full, litres held), which way it is going, and when it last came
   in. The drawing differs by sensor - a dial for a temperature, a tank or a
   dam for a water level - but the frame around it is the same, so the two
   read as one family. Colours stay in the app's vocabulary; the only new one
   is water, which is deliberately NOT --accent (blue is the action colour,
   and water is a reading, not a button). */

/* ONE 16px rhythm between every window in the stack (GK, 21 Jul 2026) - the
   readout, the History chart, Zones and Actions - matching the 16px the
   page's cards already use. The gaps live HERE; the windows themselves carry
   no vertical margins of their own. */
.sensor-list { display: flex; flex-direction: column; gap: 16px; }
.sensor-row { display: flex; flex-direction: column; gap: 16px; }

/* The drawing sits left at a fixed size so a row of sensors line up. */

/* The readout column takes the rest. */
.sensor-read { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
/* Instantly editable, exactly like an automation name (GK's "gold" rename). */

/* The number. Tabular figures so a changing reading does not jiggle. */

/* One line of derived meaning - per cent full, litres held, capacity. */

/* Freshness, the quietest line. (The trend chip that lived beside it moved
   into the Statistics section - GK, 22 Jul 2026.) */
.sensor-age { font-size: 12px; color: var(--text-dim); }

/* Thresholds read as a list of what the Controller will DO, not as numbers. */
.sensor-thresholds { display: flex; flex-direction: column; gap: 6px; margin-top: 12px; }
.sensor-threshold {
  display: flex; align-items: center; gap: 10px;
  font-size: 13px; color: var(--text-secondary);
  background: var(--surface-raised); border: 1px solid var(--border);
  border-radius: 8px; padding: 7px 10px;
}
.sensor-threshold .th-dir { flex: none; width: 16px; text-align: center; font-weight: 700; }
.sensor-threshold.up .th-dir { color: #3ecf6b; }
.sensor-threshold.down .th-dir { color: #e8a13c; }
.sensor-threshold .th-at { color: var(--text); font-weight: 600; font-variant-numeric: tabular-nums; }
.sensor-threshold .th-note { margin-left: auto; color: var(--text-dim); font-size: 12px; }

/* The vessel setup form inside the cog panel. */
/* The graph-limit rows in Options take the padding row's exact shape (GK,
   22 Jul 2026) - .pad-box/.pad-lab/.thr-unit, one look, not a copy - and
   the Statistics captions are .pad-lab too (GK: one size and style). The
   fixed caption width keeps every box and value in one column across both
   sections; scoped so the ladder's own padding row is untouched. */
.sensor-details .pad-lab,
.stats .pad-lab { min-width: 90px; }
/* --- Temperature: the LCD readout and the zone ladder ------------------ */
/* A temperature is self-describing, so it is shown as a reading rather than
   drawn on a gauge. An LCD is deliberately ONE colour whatever it reads: we
   never know what is being measured - a freezer at -18 is healthy, a kiln at
   100 is stone cold - so colouring by value would claim a meaning we do not
   have. The thresholds below are where meaning legitimately comes from,
   because the owner set them. */

.lcd {
  background: #0e1013;
  border: 1px solid #2b3038;
  border-radius: 12px;
  padding: 14px 16px 11px;
  box-shadow: inset 0 2px 14px rgba(0, 0, 0, 0.7);
}
/* Per-type readout colour: the temperature keeps GK's orange; a water level
   reads in water blue so the two sibling pages tell themselves apart. ONE
   variable drives the reading, the graph line, its gradient fill and the
   last-value dot - change it here and the whole page follows.
   GK, 25 Jul 2026: the old #2ec5c5 leaned green - water should read as water,
   and in GK's words "blue like the enabled tab". So it takes the app's own
   accent VARIABLE, not a copy of its value: restyle the accent once and the
   enabled switch, the day chips and this reading all move together. */
.sensor-list { --readout: #ffb641; }
.sensor-list[data-device-type="WTR"] { --readout: var(--accent); }
/* Check now keeps the right edge it had beside the removed cog (GK,
   22 Jul 2026 - the chart's cog is the settings' one door now). */
.lcd-top { display: flex; align-items: center; justify-content: flex-end; margin-bottom: 4px; }
/* Ask the sensor to read now - the on-demand request the device page makes. */
.lcd-check {
  font-size: 12px; font-weight: 500; color: var(--readout);
  background: transparent; border: 1px solid rgba(255, 182, 65, 0.4);
  border-radius: 8px; padding: 5px 14px; cursor: pointer;
}
.lcd-check:hover { background: rgba(255, 182, 65, 0.12); }
.lcd-check:disabled { opacity: 0.5; cursor: default; }
/* The reading, typed in Roboto Bold (GK, 22 Jul 2026 - the Inter Light cut
   retired in turn; weight 900 = Black if Bold reads too light). Centered,
   the orange kept. One colour whatever it reads. The Light cut's negative
   tracking goes with it - Bold digits need their own spacing. */
.lcd-reading {
  font-family: "Roboto", -apple-system, "Segoe UI", sans-serif;
  font-weight: 700;
  font-size: 132px;
  line-height: 1;
  letter-spacing: normal;
  color: var(--readout, var(--readout));
  text-align: center;
  font-variant-numeric: tabular-nums;
  padding: 4px 0 2px;
}
.lcd-reading-unit {
  font-size: 32px;
  font-weight: 500;  /* a step lighter than the digits, same as the Light cut's ratio */
  letter-spacing: 0;
  margin-left: 10px;
  vertical-align: 2.0em; /* em of the unit's own size - rides high, like a degree mark */
}
.lcd-idle { padding: 26px 0 22px; text-align: center; font-size: 15px; color: #454f5c; }
.lcd-foot {
  display: flex; align-items: center; justify-content: space-between;
  gap: 10px; margin-top: 6px; font-size: 11px; color: #55606e; flex-wrap: wrap;
}
.lcd-meta { display: flex; align-items: center; gap: 10px; }
.lcd .sensor-age { color: #55606e; }

/* Box and title come from the shared .collapsible / .fold-head rule - the
   only thing this section owns is its place in the page rhythm. */
.zones-help { margin-bottom: 12px; line-height: 1.45; }
.zones-empty, .zones-more {
  font-size: 12px; color: var(--text-dim); text-align: center;
  padding: 10px; border: 1px dashed var(--border); border-radius: 8px;
}
.zones-more { margin-top: 8px; }
/* The padding: a plain row at the top of Zones and Actions (GK, 21 Jul
   2026), no box of its own - it writes over the same 57 the thresholds use,
   slot 0 - with a boxed ? that explains it. */
.pad-box {
  display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
  margin-bottom: 12px;
}
.pad-lab { font-size: 15px; color: var(--text-secondary); }
/* The Options graph-limit and custom-date boxes share this rule (GK,
   22 Jul 2026) - the same box as the padding's, not a copy of its
   numbers. */
.device-edit .form-card .pad-val,
.device-edit .form-card .pad-box .sensor-input,
.device-edit .form-card .pad-box .date-box {
  width: 92px; flex: none; height: 32px; font-size: 17px; font-weight: 600;
  text-align: center; font-variant-numeric: tabular-nums; padding: 0 8px;
}
/* The date boxes carry NO overrides (GK, 22 Jul 2026: same font, same
   widths, same heights) - they are the shared rule above, identical to the
   Graph high/low boxes in every way. */
/* The example text in these boxes reads MUCH lighter than a value (GK,
   22 Jul 2026) - a hint, not a reading. */
.thr-val::placeholder, .pad-val::placeholder,
.sensor-input::placeholder, .date-box::placeholder {
  color: #4a5462; font-weight: 400;
}
.pad-help {
  flex: none; width: 28px; height: 28px; margin-left: auto;
  border: 1px solid var(--border); border-radius: 8px;
  background: transparent; color: var(--text-secondary);
  font-size: 15px; font-weight: 700; cursor: pointer;
}
.pad-help:hover { color: var(--text); border-color: #3d434c; }
.pad-info {
  width: 100%; line-height: 1.45;
}

/* A band between two thresholds, drawn as a band: its own surface and a left
   bar, so the ladder reads as zone / threshold / zone rather than a list. The
   one the reading is sitting in is marked - the app's only claim here, and it
   is a fact, not a judgement. */
/* --- The Action Builder composer (design doc 17) --------------------------
   Built from the shared pieces - chips, field rows, the one card box. Only
   the step-row layout is its own. */
.comp-chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 10px 0; }
.comp-step {
  display: flex; align-items: center; flex-wrap: wrap; gap: 8px;
  padding: 8px 0; border-bottom: 1px solid var(--border);
}
.comp-step-words { font-weight: 600; margin-right: 4px; }
.comp-step-actions { display: flex; flex-wrap: wrap; gap: 6px; }
.comp-hms { flex: 0 1 55px; }
.comp-step-unit { color: var(--text-dim); font-size: 13px; }
.comp-step-cut {
  margin-left: auto; background: none; border: none; cursor: pointer;
  color: var(--text-dim); font-size: 18px; line-height: 1; padding: 4px 8px;
}
.comp-step-cut:hover { color: var(--error); }
.comp-picker { margin-top: 10px; }
.comp-pick-row {
  display: flex; align-items: center; flex-wrap: wrap; gap: 8px;
  padding: 8px 0; border-bottom: 1px solid var(--border);
}
.comp-pick-name { flex: none; width: 160px; font-weight: 600; }
.comp-actions { display: flex; gap: 10px; margin-top: 14px; }
#auto-new { margin-bottom: 14px; }
#composer h3 { margin-top: 18px; }

.zone {
  display: flex; align-items: center; gap: 10px;
  padding: 9px 12px; border-radius: 8px;
  background: rgba(255, 255, 255, 0.015);
  border: 1px solid rgba(63, 134, 255, 0.32);
  border-left: 3px solid #3f86ff;
}
.zone.here {
  background: rgba(63, 134, 255, 0.16);
  border-color: rgba(63, 134, 255, 0.5);
  border-left-color: #5b9bff;
}
.zone-id { font-size: 14px; font-weight: 600; color: #6fa8ff; }
.zone.here .zone-id { color: #8cbaff; }
.zone-txt { font-size: 14px; color: var(--text-secondary); font-variant-numeric: tabular-nums; }
.zone.here .zone-txt { color: var(--text); font-weight: 500; }
.zone-now {
  margin-left: auto; font-size: 13px; font-weight: 600; color: #fff;
  background: var(--accent); border-radius: 10px; padding: 3px 10px;
  font-variant-numeric: tabular-nums; white-space: nowrap;
}

/* A threshold is a boxed card in the Devices-list idiom - bordered, tile
   radius, a left accent bar - and it is laid out the way the Controller's page
   lays it out: what happens going UP above the line, the threshold as the line,
   what happens going DOWN below it. */
.thr {
  margin: 6px 0; border: 1px solid var(--border);
  border-left: 3px solid var(--readout); /* the readout's own orange (GK, 21 Jul 2026) */
  border-radius: var(--radius-tile); background: var(--surface); overflow: hidden;
}
.thr-line {
  display: flex; align-items: center; gap: 10px;
  padding: 10px 12px; background: var(--surface-raised);
  border-top: 1px solid var(--border); border-bottom: 1px solid var(--border);
}
.thr-id { font-size: 15px; color: var(--readout); }
/* The threshold value is editable in place. The Controller still owns it -
   it clamps to the neighbouring thresholds and pushes the set to the device -
   so the ladder is re-read after every write. */
.device-edit .form-card .thr-val {
  width: 92px; flex: none; height: 32px; font-size: 17px; font-weight: 600;
  text-align: center; font-variant-numeric: tabular-nums; padding: 0 8px;
}
/* No spinners: the value is typed, and the little arrows only invited a nudge
   past a neighbour (GK). The graph limit cells share the rule (GK, 22 Jul
   2026) - one rule, not a lookalike copy. */
.thr-val::-webkit-outer-spin-button,
.thr-val::-webkit-inner-spin-button,
.pad-val::-webkit-outer-spin-button,
.pad-val::-webkit-inner-spin-button,
.sensor-input::-webkit-outer-spin-button,
.sensor-input::-webkit-inner-spin-button,
.date-box::-webkit-outer-spin-button,
.date-box::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.thr-val, .pad-val, .sensor-input, .date-box { -moz-appearance: textfield; appearance: textfield; }
/* An unset slot reads as empty rather than as something switched off. */
.thr-id.is-unset, .thr-unit.is-unset { color: var(--text-dim); }
.thr-unit { font-size: 14px; color: var(--text-dim); }
.thr-clear {
  margin-left: auto; font-size: 13px; color: var(--text-dim);
  background: transparent; border: 1px solid var(--border);
  border-radius: 8px; padding: 4px 10px; cursor: pointer;
}
.thr-clear:hover { color: var(--text-secondary); border-color: #3d434c; }
/* An unused slot carries no orange at all - the orange means "this threshold is
   doing something", and an empty one is not. */
.thr-empty { border-color: #2a2f38; border-left-color: #3d434c; }
.thr-empty .thr-line { background: transparent; }

.act {
  display: flex; align-items: center; gap: 9px;
  padding: 8px 12px; font-size: 14px;
}
.act-dir {
  font-size: 15px; width: 52px; flex: none;
  color: var(--readout); letter-spacing: 0.01em;
}
/* The arrow carries the direction, so it is the strong mark on the line. */
.act-arrow {
  flex: none; width: 20px; text-align: center;
  font-size: 20px; font-weight: 800; line-height: 1;
  color: var(--readout); text-shadow: 0 0 10px rgba(255, 182, 65, 0.4);
}

/* Trigger fires the list beside it. Grey and inert when nothing is bound -
   there is no point offering to run an empty list. */
.act-cog { margin-left: auto; width: 28px; height: 28px; }
.act-run {
  flex: none;
  font-size: 14px; font-weight: 500; color: #fff;
  background: #3f86ff; border: 1px solid #3f86ff;
  border-radius: 8px; padding: 5px 14px; cursor: pointer;
}
.act-run:hover { background: #5b9bff; border-color: #5b9bff; }
.act-run.is-idle {
  color: var(--text-dim); background: transparent;
  border-color: var(--border); cursor: default;
}
.act-what { color: var(--text); font-size: 15px; font-weight: 600;
            font-variant-numeric: tabular-nums; }
.act-what.none { color: var(--text-dim); font-weight: 500; }

@media (max-width: 640px) {
  .lcd { padding: 12px 12px 10px; }
  .lcd-reading { font-size: 92px; }
  .lcd-reading-unit { font-size: 24px; }
    .act-dir { width: 46px; font-size: 14px; }
  .act-run { min-height: 40px; }
  .device-edit .form-card .thr-val { width: 84px; min-height: 44px; }
  .thr-clear { min-height: 44px; }
  /* A whole From/To row - caption, three boxes, its button - fits ONE
     phone row (GK, 22 Jul 2026): every box in the family narrows to one
     shared width and a 16px face (measured: the row has 293px to give and
     needs every one of these), the caption column and gaps tighten with
     them. */
  .sensor-details .pad-box { gap: 6px; }
  .sensor-details .pad-lab, .stats .pad-lab { min-width: 72px; }
  .device-edit .form-card .pad-box .sensor-input,
  .device-edit .form-card .pad-box .date-box {
    width: 47px; padding: 0 3px; font-size: 16px;
  }
}

/* --- The sensor history graph (GK, 21 Jul 2026) --------------------------- */
/* The bounded reading history on the readout's own dark face; the line takes
   the per-type readout colour. The vertical range is the owner's (the cog's
   app-only graph limits), or the data's when unset. */
.sensor-graph {
  background: #0e1013; border: 1px solid #2b3038;
  border-radius: 12px; padding: 14px;
}
.graph-title { font-size: 17px; font-weight: 500; margin-bottom: 8px; }
/* The span chips and the download, under the chart's title (GK, 21 Jul 2026).
   They wrap rather than scroll, so a 375px phone shows all five and the
   download drops to its own line instead of overflowing the card. */
.graph-spans {
  display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
  margin: 0 0 10px;
}
.span-chip {
  background: transparent; color: var(--text-secondary);
  border: 1px solid var(--border); border-radius: 10px;
  padding: 4px 11px; font-size: 12px; font-family: inherit;
  cursor: pointer; transition: color .15s, border-color .15s, background .15s;
}
.span-chip:hover { color: var(--text); border-color: #3a414c; }
.span-chip.is-on {
  color: var(--text); border-color: var(--accent);
  background: rgba(46, 107, 255, 0.14);
}
/* Standalone under its own DOWNLOAD heading since 22 Jul 2026 (GK) - the
   inline-block replaces the flex-row margin it used to lean on. */
.graph-dl {
  display: inline-block; font-size: 12px; color: var(--text-secondary);
  text-decoration: none; border: 1px solid var(--border);
  border-radius: 10px; padding: 4px 11px; transition: color .15s, border-color .15s;
}
.graph-dl:hover { color: var(--text); border-color: #3a414c; }

/* Opened from the chart's cog the panel sits INSIDE the chart card (GK), so
   it drops its own card dressing and is divided off by a rule instead - one
   box, a chart with its settings under it, not a card within a card. */
.sensor-graph .out-settings {
  background: transparent; border: none; border-radius: 0;
  border-top: 1px solid var(--border);
  margin: 12px -14px 0; padding: 12px 14px 0;
}
.graph-span { font-size: 13px; color: var(--text-dim); font-weight: 400; margin-left: 8px; }
/* The axis titles sit hard against the viewBox edges now that the padding is
   tight - their ink reaches -0.5 and 193.5 of a 0..192 box. The card's own
   14px padding has room for that, so let it paint rather than clip (the UA
   default for an SVG viewport is overflow: hidden). Nothing else in the
   chart reaches the edge: the trace is bounded by its own geometry. */
.graph-svg { display: block; width: 100%; height: auto; overflow: visible; }
/* Zones and Actions folds on the shared .collapsible / .fold-head rule - no
   rules of its own beyond the title's existing look. */
/* The visual hierarchy of the chart's furniture (GK, 21 Jul 2026): the two
   AXES are solid and brighter, the interior break-up lines are DASHED and
   dim, and the white thresholds sit brighter still - three layers a glance
   can tell apart. */
.graph-grid { stroke: #2b3240; stroke-width: 1; stroke-dasharray: 3 5; }
.graph-axisline { stroke: #8a93a0; stroke-width: 1.2; }
.graph-axis-title { fill: #8a93a0; font-size: 9px; letter-spacing: 0.06em; }
/* The window's low and high moved to the Statistics section (GK, 22 Jul
   2026) - the chart's title row keeps only its own furniture. */
/* Statistics rows ARE .pad-box rows with .pad-lab captions (GK, 22 Jul
   2026: one size and style) - the only rule of their own is the value's
   look, and the rows' shared bottom margin already spaces the note. */
.stat-val { font-size: 17px; font-weight: 600; font-variant-numeric: tabular-nums; }
.stat-note { font-size: 12px; color: var(--text-dim); }
.graph-pager { float: right; display: inline-flex; gap: 4px; margin: -2px 0 0 8px; }
.graph-page {
  background: transparent; color: var(--text-secondary);
  border: 1px solid var(--border); border-radius: 8px;
  min-width: 26px; height: 26px; padding: 0 7px; font-size: 12px;
  font-family: inherit; cursor: pointer; transition: color .15s, border-color .15s;
}
.graph-page:hover:not(:disabled) { color: var(--text); border-color: #3a414c; }
.graph-page:disabled { opacity: 0.35; cursor: default; }
.graph-page-now { color: var(--accent); border-color: var(--accent); }
/* The reading line is the readout's colour (GK, 21 Jul 2026, reversing the
   white-line trial of the same day): the line, the fill under it and the big
   number all say "this is the reading", and the THRESHOLDS are the white
   reference lines it is judged against. */
/* The DOTS are the record now and the line is a hint of the shape between
   them (GK, 6 Aug 2026: "the readings need to be dots on the graph and the
   line much fainter"). Each dot is a check that really happened; where the
   sensor was quiet there is neither. */
.graph-line {
  fill: none; stroke: var(--readout, var(--readout)); stroke-width: 1.4;
  stroke-linejoin: round; stroke-linecap: round; opacity: 0.4;
}
.graph-check { fill: var(--readout, var(--readout)); opacity: 0.95; }
.graph-dot { fill: var(--readout, var(--readout)); }
/* The OFF-SCALE marker (GK, 27 Jul 2026): where a reading passes the owner's
   Graph high/low the line runs up to the limit and STOPS - there is NO line
   while the value is off the chart, because a line along the edge reads as
   "it sat at the limit" (GK: "it still looks like the temperatures go straight
   along the top"). This triangle over the gap is then the only thing saying
   WHICH way it went, so it carries real weight; the caption under the chart
   gives the value it reached. */
.graph-mark-off { fill: var(--readout, var(--readout)); opacity: 0.9; }
.graph-axis { fill: #55606e; font-size: 9px; font-variant-numeric: tabular-nums; }
/* SVG text is sized in viewBox units, so it shrinks with the chart: on a
   375px phone the card renders the 560-unit viewBox at ~53%, which put the
   axis at 6.3 real pixels - unreadable, and the time ticks exist to BE read.
   Scaling the user-unit size back up lands them near 11px on a phone while
   desktop is untouched. */
@media (max-width: 640px) {
  .graph-axis { font-size: 16px; }
  .graph-thr-lab { font-size: 14px; }
  .graph-axis-title { font-size: 15px; }
}
/* Thresholds on the graph (GK, 21 Jul 2026): the ladder's boundaries over the
   history, only when the owner turns them on. Dotted LINES only - the
   alternate-zone tint (.graph-zone) was deleted 25 Jul 2026 with the code that
   drew it (GK: "just have the dotted line, do not need any shading"). */
.graph-thr { stroke: #f2f3f5; stroke-width: 1; stroke-dasharray: 5 4; opacity: 0.7; }
.graph-thr-lab { fill: #f2f3f5; font-size: 11px; font-variant-numeric: tabular-nums; opacity: 0.85; }
/* A checkbox row in the settings panel. The row layout itself comes from the
   shared label.form-check exception - the device page's label rule stacks
   every label into a column (caption above box), and form-check is the
   established way back to box-beside-words for a checkbox. */
.sensor-check { margin-top: 12px; cursor: pointer; }
.sensor-check input { cursor: pointer; }

/* --- Camera (GK, 21 Jul 2026): the development live view ------------------ */
.camera-stage {
  position: relative; overflow: hidden; border-radius: var(--radius-tile);
  border: 1px solid var(--border); background: #000;
  aspect-ratio: 16 / 9; touch-action: none; user-select: none;
}
.camera-stage img {
  width: 100%; height: 100%; object-fit: contain; display: block;
  transform-origin: 0 0; will-change: transform; -webkit-user-drag: none;
}
.camera-controls { display: flex; gap: 8px; align-items: center; margin-top: 12px; }
.camera-controls .btn { min-width: 44px; }

/* --- Sign in (GK item 8, 20 Jul 2026) ------------------------------------ */
.login-shell { display: flex; justify-content: center; padding-top: 12vh; }
.login-card {
  width: 100%;
  max-width: 360px;
  display: flex;
  flex-direction: column;
  gap: 14px;
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 12px;
  padding: 28px;
}
.login-title { font-size: 20px; letter-spacing: 2px; margin: 0; }
/* The guest code page leads with the app's own brand (GK, 23 Jul 2026) -
   the shared .brand pieces, centred in the card, no header nudge. */
.login-brand { justify-content: center; margin-top: 0; }
/* The guest's hello + code box (GK, 23 Jul 2026): the hello reads easily
   at arm's length - bigger and lighter than the owner login's blurb; the
   code box is the card's clear focal point (raised face, brighter edge,
   big centred digits) while its "Access code" prompt stays a quiet hint. */
.guest-code-card .login-sub { font-size: 16px; color: var(--text-secondary); }
/* Two-line hello, lines kept tight, then a clear gap before the code box
   (GK, 23 Jul 2026) - the card's 14px flex gap plus this margin. */
.guest-code-card .guest-hello { line-height: 1.45; margin-bottom: 6px; }
.guest-code-card .input-basic {
  background: var(--surface-raised);
  border-color: #3a3f47;
  font-size: 18px;
  text-align: center;
  letter-spacing: 0.2em;
  padding: 12px;
}
.guest-code-card .input-basic::placeholder {
  color: var(--text-dim);
  font-size: 14px;
  letter-spacing: normal;
}
.login-sub { margin: 0; color: var(--text-dim); font-size: 13px; }
/* The first-run page's one heading (GK's ruling, 27 Aug 2026: nothing is
   served until a password is set): the sign-in card with the job named
   above the boxes. */
.first-run-heading { margin: 0; font-size: 16px; font-weight: 600; }
/* Forgot password: a quiet text link, not another button (the card's one
   action is Sign in). The recovery box it opens reuses the card's stack. */
.login-forgot {
  background: none; border: none; padding: 0; cursor: pointer;
  color: var(--text-secondary); font-size: 13px; text-align: left;
  text-decoration: underline;
}
.login-forgot:hover { color: var(--text); }
#recover-box { display: flex; flex-direction: column; gap: 10px; }
.nav-logout { margin-left: auto; }

/* The circular ? beside a section title, and the comment box it opened,
   were removed app-wide (GK, 24 Jul 2026) - along with .help-btn,
   .help-info and .ai-help, which nothing renders any more. */

/* --- AI Permissions (GK, 21 Jul 2026) ------------------------------------ */
/* The warning is the one loud thing on the page - amber, bordered, and it
   does not fold away into grey footnote text. */
.ai-warning {
  /* aligned to the 16px in-card law, 16 Aug 2026 */
  margin: 16px 0 0;
  padding: 10px 12px;
  font-size: 13px;
  line-height: 1.5;
  color: var(--readout);
  background: rgba(255, 182, 65, 0.07);
  border: 1px solid rgba(255, 182, 65, 0.35);
  border-left: 3px solid var(--readout);
  border-radius: 8px;
}
/* The one-tap levers, spaced off the warning (GK: "provide a space"). */
.ai-scope-levers { display: flex; gap: 10px; margin: 16px 0 14px; }

/* --- Sharing (GK item 9, 20 Jul 2026) ------------------------------------ */
.share-devices { display: flex; flex-direction: column; gap: 8px; margin-top: 16px; } /* aligned to the 16px in-card law, 16 Aug 2026 */
.share-device-head { display: flex; align-items: center; gap: 8px; font-size: 14px; }
.share-outputs { display: flex; flex-wrap: wrap; gap: 6px 14px; margin: 6px 0 2px 26px; }
.share-output { display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-secondary); }
.share-list { display: flex; flex-direction: column; gap: 8px; margin-top: 16px; } /* aligned to the 16px in-card law, 16 Aug 2026 */
/* A directory row (GK, 23 Jul 2026): name fills the width, Edit + Withdraw
   stay together on the right - single line, never cramped on a phone. */
.share-row { display: flex; align-items: center; gap: 8px; }
.share-who { flex: 1; min-width: 0; font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.share-link { flex: 1; min-width: 120px; font-size: 12px; }
/* The guest's access code beside the link (GK) - monospace so it reads as
   a code; Copy takes link + code together. */
.share-code { flex: none; font-family: "SF Mono", ui-monospace, Menlo, monospace; font-size: 12px; color: var(--text-secondary); white-space: nowrap; }
.share-expired .share-who { color: var(--text-dim); }
/* The review line under each share: exactly what it reaches, by name. */
.share-scope { flex-basis: 100%; margin-top: 2px; }
/* The owner's instructions for the guest (GK, 22 Jul 2026) - one shared
   rule for the Setup card and the share's own page. */
.share-instructions { width: 100%; margin-top: 4px; resize: vertical; }
/* The guest-owner thread (GK, 22 Jul 2026) - the share page and the
   Guest Portal's Messages section share these rules. */
/* The guest-owner thread reads as a CHAT (GK, 23 Jul 2026) - the SAME
   bubble vocabulary as the AI window (.chat-row / .chat-bubble / .chat-text
   / .chat-meta), never a copy: your own messages right in accent blue, the
   other side left in grey. The one difference from the AI shell is the log
   is a bounded scroll box inside a card, not the full-window flex child. */
.message-list {
  display: flex; flex-direction: column; gap: 10px;
  min-height: 90px; max-height: 340px; overflow-y: auto;
  padding: 14px; background: var(--bg);
  border: 1px solid var(--border); border-radius: 12px;
}
/* An empty / "could not read" note sits centred and quiet, not as a bubble. */
.message-list:not(:has(.chat-row)) {
  align-items: center; justify-content: center;
  color: var(--text-dim); font-size: 13px; text-align: center;
}
.message-compose { display: flex; align-items: flex-end; gap: 8px; margin-top: 12px; }
.message-compose textarea { flex: 1; resize: vertical; }
/* The access log (GK, 22 Jul 2026): when / what happened / from where -
   ONE line per entry (GK, 23 Jul: no wrap, tight rows), the address
   ellipsised on the right rather than dropping to a second line. */
.access-list { display: flex; flex-direction: column; gap: 3px; font-size: 13px; }
.access-row { display: flex; align-items: center; gap: 10px; }
.access-when { flex: none; color: var(--text-secondary); white-space: nowrap; }
.access-outcome { flex: none; white-space: nowrap; }
.access-outcome[data-outcome="wrong_code"],
.access-outcome[data-outcome="throttled"] { color: #e8a13c; }
.access-from { flex: 1; min-width: 0; text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Access-time rules (GK, 20 Jul 2026): day chips + a daily range per row. */
.share-times { margin-top: 12px; }
.share-rule { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 8px 0; }
.share-rule-days { display: flex; gap: 4px; flex-wrap: wrap; }
.share-day { padding: 4px 8px; font-size: 12px; }
.share-day[data-on="true"] { background: var(--accent); border-color: var(--accent); color: #fff; }
.share-rule-from, .share-rule-to { width: 110px; flex: none; }
/* The read-only name is a span, so (unlike the editable input) its text
   does not centre in the 34px row - it sat at the top, out of line with
   the number (GK, 23 Jul 2026). Centre it like the input does. */
.guest-name { font-size: inherit; display: flex; align-items: center; }
/* --- FusionCore Connection card (GK, 23 Jul 2026): address + scannable QR - */
.connection-body { display: flex; gap: 18px; align-items: flex-start; flex-wrap: wrap; }
/* The QR MUST sit on white with a quiet margin whatever the theme, or a
   camera cannot read it - the tile frames it. The tile's own 10px padding
   IS the quiet zone (GK, 23 Jul 2026: "only 10px" - the PNG bakes no
   border of its own), and the rounding shows in the white because the
   image sits inside it, not over it. */
/* 75% of the original 180px tile (GK, 23 Jul 2026). */
.connection-qr { flex: none; width: 135px; height: 135px; padding: 10px; background: #fff; border-radius: 10px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; }
/* The QR is a high-res PNG the browser downscales SMOOTHLY into the tile
   (GK, 23 Jul 2026) - it FILLS the tile; the ~10px white frame is the
   2-module quiet zone baked into the PNG itself (GK: the old 72% + 4-module
   border read as a ridiculous white frame). Smooth downscale keeps every
   module equal (decode-verified at display size); the old CSS-scaled
   SVG + crispEdges snapped modules unevenly and scanned badly. */
.connection-qr img { display: block; width: 100%; height: 100%; image-rendering: auto; }
/* The three lines are body-text PARAGRAPHS - the standard 10px rhythm,
   same as everywhere (GK, 23 Jul 2026: "It is body text, Make it all
   the SAME!"). */
.connection-detail { flex: 1; min-width: 200px; display: flex; flex-direction: column; gap: 10px; }
/* The label, the address and the note all inherit the ONE body colour
   (GK, 23 Jul 2026: "Only H2, H3 etc. should be different") - no box,
   boxes mean editable; a tap selects the whole address. */
/* Caption-then-box on ONE row - GK's standard for every text entry (set
   on the Temp graph's Graph high/low, asked of the app 23 Jul 2026:
   "for all text entry boxes... what it is then the box"). The caption
   column is fixed so the boxes align; rows ride the 10px body rhythm. */
/* The rows block joins the same 10px body rhythm as paragraphs - one
   spacing everywhere (GK, 23 Jul 2026). */
/* The caption column FITS its card's captions (GK, 16 Aug 2026: "Why
   are the Name and actual name so far apart, couldnt the boxes be
   closer?") - a grid whose first column is max-content, so short
   captions sit close to their boxes, long ones still align, and there
   is no magic width left to drift. display:contents keeps each row's
   label semantics (click a caption, focus its box) while the grid owns
   the columns - still ONE shared rule for every caption-then-box row. */
.field-rows {
  display: grid;
  grid-template-columns: max-content 1fr;
  column-gap: 14px;
  row-gap: 10px;
  align-items: center;
  /* aligned to the 16px in-card law, 16 Aug 2026 */
  margin-top: 16px;
}
.field-row { display: contents; }
.field-row-lab { width: auto; }
/* A PARAGRAPH row stacks: its caption sits ABOVE the wide box (GK,
   16 Aug 2026: "the description should be above the description box so
   that it is better on the mobile") - both span the grid's full width,
   so the box gets the whole card on any screen. Single-line rows keep
   the caption-beside-box shape. */
.field-row-grow .field-row-lab,
.field-row-grow .input-basic { grid-column: 1 / -1; }

/* Scan rides the END of the Select Port row (GK, 16 Aug 2026) - one
   grid cell holding the picker and its button side by side, so the row
   still reads as caption-then-control like every other. */
.select-port-cell { display: flex; gap: 10px; align-items: center; min-width: 0; }
.select-port-cell .select { flex: 0 1 280px; width: auto; min-width: 0; }
/* Boxes are sized for their CONTENT, never miles long (GK, 23 Jul
   2026): 280px for text/password/email, compact for numbers - a
   type-based rule, not a per-card treatment. They shrink on a phone. */
/* A dropdown in one of these rows sizes with the boxes (GK, 24 Jul 2026 -
   the Time zone picker): same rule, so they can never drift apart. Without
   it a select widens to its longest option and breaks the column. */
/* Grid items take widths, not flex-basis: same content-sized boxes. */
.field-row .input-basic, .field-row .select {
  width: 280px;
  max-width: 100%;
  min-width: 0;
  justify-self: start;
}
.field-row input[type="number"].input-basic { width: 110px; }
.field-row textarea.sys-desc-box { width: 100%; max-width: 560px; }

/* The Alarms volume row (GK, 27 Aug 2026: "Need to be able to turn up
   the volume of alarms") - the app's first range input, so the house
   look is written here from the tokens: a border-tone track, an accent
   thumb with the accent's glow, control-height so the row lines up with
   the boxes above it. Track and thumb need the vendor pseudo-elements
   written out twice; a combined selector a browser half-recognises is
   dropped whole. */
.volume-cell { display: flex; gap: 12px; align-items: center; min-width: 0; }
/* The readout holds its widest width so 9% -> 100% cannot shuffle the row. */
.volume-cell output { min-width: 44px; font-size: 15px; color: var(--text-body); }
input[type="range"].range {
  appearance: none;
  -webkit-appearance: none;
  width: 224px;
  max-width: 100%;
  min-width: 0;
  height: var(--control-h);
  margin: 0;
  background: transparent;
  cursor: pointer;
}
input[type="range"].range::-webkit-slider-runnable-track {
  height: 6px;
  border-radius: 3px;
  background: var(--border);
}
input[type="range"].range::-moz-range-track {
  height: 6px;
  border-radius: 3px;
  background: var(--border);
}
input[type="range"].range::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: 20px;
  height: 20px;
  border-radius: 50%;
  border: none;
  background: var(--accent);
  box-shadow: 0 0 6px var(--accent-glow);
  /* Centres the thumb on the 6px track: (20 - 6) / 2. */
  margin-top: -7px;
}
input[type="range"].range::-moz-range-thumb {
  width: 20px;
  height: 20px;
  border-radius: 50%;
  border: none;
  background: var(--accent);
  box-shadow: 0 0 6px var(--accent-glow);
}
input[type="range"].range:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.conn-address-label { margin: 0; }
/* The address itself: bold #eee (GK, 23 Jul 2026) - the one focal value
   on the card. */
.conn-address { margin: 0; user-select: all; overflow-wrap: anywhere; font-weight: 700; color: #eee; }
/* The parenthetical under the address reads in italics, tight under it -
   no footnote top margin (GK, 23 Jul 2026: "Remove the spaces"). */
.conn-note { font-style: italic; margin-top: 0; }
/* --- The Guest Portal (GK, 22 Jul 2026) ----------------------------------- */
/* The page's title is a TAG (GK: "make it a nice tag") - the accent pill,
   sitting above the folding cards. */
.guest-portal-tag { display: inline-block; margin-bottom: 16px; padding: 6px 16px; font-size: 15px; font-weight: 600; letter-spacing: 0.4px; color: var(--accent); background: rgba(46, 107, 255, 0.12); border: 1px solid var(--accent); border-radius: 10px; }
.guest-instructions { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
.guest-revoke { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
/* The guest dashboard (GK, 20 Jul 2026): one block per shared device,
   rows in the device page's own style. */
.dash-device { margin-top: 24px; display: flex; flex-direction: column; gap: 18px; }
.dash-device:first-child { margin-top: 0; }
.dash-device-name { font-weight: 600; font-size: 15px; color: var(--text-secondary); }

/* --- The Alarms & Alerts DEMO page (GK overnight list, 22 Jul 2026) ------- */
/* Alarms stay pinned while the rest scrolls under them (GK: "Alarms remain
   up the top, pinned. Alerts can scroll"). */
.demo-note { margin-bottom: 16px; }
.alarm-card {
  position: sticky; top: 10px; z-index: 5;
  border-left: 3px solid var(--error);
}
.demo-alarm-row {
  display: flex; align-items: baseline; gap: 10px;
  padding: 8px 12px; margin-bottom: 8px;
  border: 1px solid rgba(224, 82, 82, 0.5); border-radius: 8px;
  background: rgba(224, 82, 82, 0.08);
}
.alert-scroll {
  max-height: 320px; overflow-y: auto;
  display: flex; flex-direction: column; gap: 8px;
}
.demo-alert-row {
  display: flex; align-items: baseline; gap: 10px;
  padding: 6px 12px; border: 1px solid var(--border); border-radius: 8px;
}
.demo-when {
  flex: none; color: var(--text-dim); font-size: 12px;
  font-variant-numeric: tabular-nums;
}
.demo-controls {
  display: flex; flex-wrap: wrap; align-items: center; gap: 10px;
}
.demo-thresh { display: flex; align-items: center; gap: 8px; margin: 12px 0; }
.demo-thresh .input-basic { width: 64px; text-align: center; }
.missed-row { display: flex; align-items: baseline; gap: 10px; padding: 5px 0; }
.missed-lab { min-width: 90px; color: var(--text-secondary); flex: none; }
.missed-text { }

/* --- A share's own page (GK, 22 Jul 2026) --------------------------------- */
/* The invitation is the whole card now (GK, 26 Aug 2026: no duplicated
   Link / Access code rows above it) - a rendered message, not a textarea,
   so the link and the code can carry their own styling. */
.invite-preview {
  width: 100%; box-sizing: border-box; font-family: inherit;
  line-height: 1.5; margin-bottom: 12px;
  border: 1px solid var(--border); border-radius: 10px;
  background: var(--surface); padding: 12px 14px;
  overflow-wrap: anywhere;
}
/* Editable (GK, 26 Aug 2026) - working in it turns the border blue, the
   app's one focus language. */
.invite-preview:focus { outline: none; border-color: var(--accent); }
.invite-preview p { margin: 0 0 12px; }
.invite-preview p:last-child { margin-bottom: 0; }
/* The link reads as a link - blue and underlined (GK's words). */
.invite-msg-link { color: var(--accent); text-decoration: underline; }
/* The access code is the thing a guest must not miss - bold and blue,
   with the tabular digits kept from the old standalone row. */
.invite-msg-code {
  font-weight: 700; color: var(--accent);
  letter-spacing: 0.08em; font-variant-numeric: tabular-nums;
}

/* --- The Notices page (GK, 24 Jul 2026) ----------------------------------- */
/* Rows mirror the Controller's list: a tier tag, the message in a fixed
   column, the raise time on the right. Alarms carry the amber the app
   already uses for attention (the share list's unread tag). */
.notice-row { display: flex; align-items: baseline; gap: 12px; padding: 8px 0; border-bottom: 1px solid var(--border); }
.notice-row:last-child { border-bottom: none; }
/* Rows carrying TWO clocks (raised + acked, or the history's three) hold
   more than a phone can fit beside the message - so unlike the plain
   standing row they may wrap: the clocks drop to their own line under the
   message rather than crushing it to a letter a line (27 Aug 2026). */
#acked-list .notice-row,
#history-list .notice-row { flex-wrap: wrap; }
.notice-tag { flex: none; width: 34px; text-align: center; font-size: 13px; font-weight: 700; color: var(--text-secondary); border: 1px solid var(--border); border-radius: 6px; padding: 2px 0; }
.notice-tag-alarm { color: var(--readout); border-color: rgba(255, 182, 65, 0.55); background: rgba(255, 182, 65, 0.07); }
.notice-msg { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.notice-when { flex: none; font-size: 12px; color: var(--text-dim); }
/* The clocks are ONE flex unit riding the row's right edge - so when the
   row is too narrow for them, they wrap UNDER the message together, both
   on the same line (GK, 27 Aug 2026: "When on mobile, both details rows
   should wrap under, so both are on the same row"). */
.notice-clocks { display: flex; gap: 12px; margin-left: auto; flex: none; }
@media (max-width: 640px) {
  .notice-clocks { flex-basis: 100%; margin-left: 0; }
}

/* TIER COLOUR = SEVERITY, and only severity (GK, 27 Aug 2026: "P1 need to
   be in Red Box and red writing... Other P levels orange. Notifications
   colour?"). P1 wears a red BOX and red writing - the status-lamp pure
   red (#ff0000, the .dot-bad lamp / nav-alarm red: a PROVEN problem).
   P2/P3 wear the same treatment in the app's own attention orange
   (var(--readout), #ffb641 - the notice tag and VC warnings already
   speak it; never a new colour). A plain Notice carries NO colour,
   deliberately: the app's colour law says blue is action and red is a
   proven problem, so a Notice's plainness IS its message (recommended to
   GK 27 Aug 2026, adopted as the default pending his veto). The app is
   its one dark face - both colours are tuned against it, same as the
   status lamps. */
/* ⚑ THE ROW'S OWN BOX IS GONE, EVERYWHERE (GK, 1 Sep 2026, of a P1 in
   the Alerts list: "Just the P1 in a box like attached should be in
   place. Not like the multiple layers as shown").

   ⚠ He asked for exactly this on 27 Aug - "should not be in a red box.
   Just have P1 etc in a box" - and it was applied to the HISTORY list
   alone, which is why he had to say it twice. A box inside a box reads as
   two competing containers and neither wins. THE CHIP IS THE SIGNAL: it
   keeps its full-colour box, the row keeps its colour but not its border,
   and the tier is legible at a glance without shouting twice.

   The tier rows are therefore structurally identical to plain ones now -
   same padding, same divider, same rhythm - which is also what keeps a P
   row and an N row the same height (GK, 27 Aug: "Spacing needs to be
   even. Should go without saying."). */
.notice-p1 .notice-msg { color: #ff0000; }
.notice-p1 .notice-tag,
.notice-p1 .notice-tag-alarm { color: #ff0000; border-color: rgba(255, 0, 0, 0.55); background: rgba(255, 0, 0, 0.07); }
.notice-p2 .notice-msg,
.notice-p3 .notice-msg { color: var(--readout); }
/* History rows are PLAIN (GK, 27 Aug 2026: "Items in Alert History
   should not be in a red box. Just have P1 etc in a box. and red or
   coloured") - the row sheds the tier BOX and text colour while the
   CHIP, coloured by the same tier class, keeps its box at full colour.
   Sits after the tier rules so equal specificity resolves this way. */
/* ⚑ Its box-shedding rules LEFT on 1 Sep 2026 - no row wears a tier box
   any more, so there is nothing here to undo. What remains is History's
   own rule: its rows carry no tier TEXT colour either, because a
   departed record is not a live signal. */
.notice-plain .notice-msg { color: var(--text); }
/* The filters sit just under the History heading (GK, 27 Aug 2026), the
   remaining controls on one quiet line at the bottom: Show more, the CSV
   Download, and the owner's Clear. One 10px gap and the shared control
   height throughout - "Spacing needs to be even. Should go without
   saying." The tier select hugs its content instead of flexing wide. */
/* Each control is a PERCENTAGE of the row with a max width (GK, 27 Aug
   2026: "I just want the buttons to be a percentage with a max width
   each") - a quarter each, capped so they don't sprawl on a wide screen
   and shrinking together on a narrow one, one line throughout, no
   breakpoints. */
.history-filters { display: flex; gap: 8px; margin: 2px 0 10px; }
.history-filters > * {
  flex: 1 1 25%;
  min-width: 0;
  max-width: 150px;
  text-align: center;
  padding-left: 8px;
  padding-right: 8px;
}
/* The tier select wears the app's OWN chevron (the pattern the DEVICES
   page's filter bar already uses), so its distance from the edge is
   ours to set - the native
   arrow jams against the border and cannot be moved (GK, 27 Aug 2026:
   "move that arrow off the side of the box a bit"). The value is
   centred, so it keeps clear of both the chevron and the left edge. */
/* ⚑ The chevron ITSELF moved to .select on 1 Sep 2026 - this row keeps
   only its own tighter SPACING, because a compact filter row wants the
   arrow closer in than a form field does. One image, two distances. */
.history-filters .select {
  background-position: right 12px center;
  padding-right: 30px;
  padding-left: 14px;
}
.history-controls { display: flex; gap: 10px; margin-top: 12px; flex-wrap: wrap; }

/* The app's own confirm dialog (GK, 27 Aug 2026) - replaces the native
   confirm whose title line is the browser's origin stamp. The card rides
   the app's tokens; the title is the bold Warning GK asked for; the body
   keeps its line breaks (pre-line). */
.fc-modal-overlay {
  position: fixed; inset: 0; z-index: 1000;
  background: rgba(0, 0, 0, 0.6);
  display: flex; align-items: center; justify-content: center;
  padding: 20px;
}
.fc-modal {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 12px;
  padding: 20px;
  max-width: 420px; width: 100%;
}
.fc-modal-title { font-size: 17px; font-weight: 700; margin-bottom: 10px; }
.fc-modal-body { white-space: pre-line; font-size: 15px; margin-bottom: 16px; }
.fc-modal-buttons { display: flex; gap: 10px; justify-content: flex-end; }

/* --- The Dashboard: a wall of Stream-Deck keys (GK, 6 Aug 2026) ----------- */
/* "I would like the aesthetics to look very similar to the elgato stream
   deck." So: square keys, one thing each, lit when live, pressable - laid on
   the app's own dark face rather than a new palette. Colour comes from the
   SAME --readout variable the sensor pages use (orange for temperature, the
   accent blue for water), so a tile and its device page cannot drift apart. */

.dash-head {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: 16px; margin-bottom: 12px;
}
.dash-edit { flex: none; }
.dash-empty { margin-top: 4px; }

/* auto-fill, so the wall reflows from one column on a phone to as many keys
   as the content width holds - no per-page width rule, the shared .content
   still owns the page (project rule). */
.dash-wall {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(116px, 1fr));
  gap: 12px; margin: 16px 0;
}

.tile {
  position: relative; aspect-ratio: 1; overflow: hidden; isolation: isolate;
  display: flex; flex-direction: column; justify-content: flex-end;
  padding: 10px; box-sizing: border-box;
  border: 1px solid #2b313a; border-radius: 18px;
  cursor: pointer; user-select: none; -webkit-user-select: none;
  -webkit-tap-highlight-color: transparent; touch-action: manipulation;
  /* A moulded key: a dark screen under a piece of clear plastic. The
     background is the SCREEN - flat and slightly domed by its own vertical
     gradient - and ::before is the plastic over it (GK, 6 Aug 2026). */
  background:
    radial-gradient(115% 80% at 50% -10%, #1b1f26 0%, #101318 55%, #0c0e12 100%);
  /* Outside: it sits above the page. Inside: a bright top lip and a dark
     bottom one, which is what makes a flat rectangle read as domed. */
  box-shadow:
    0 6px 14px rgba(0, 0, 0, 0.55),
    0 1px 2px rgba(0, 0, 0, 0.6),
    inset 0 1px 0 rgba(255, 255, 255, 0.10),
    inset 0 -2px 3px rgba(0, 0, 0, 0.55),
    inset 0 0 0 1px rgba(255, 255, 255, 0.03);
  transition: transform .12s ease, border-color .15s, box-shadow .15s;
  --readout: var(--text-secondary);
}
/* THE PLASTIC (GK's reference image, 6 Aug 2026). A moulded piece of clear
   plastic laid over the key: a bright specular sweep running down from the
   top-left, a soft bloom across the top, and the light falling away to
   nothing before the bottom-right corner. Sits ABOVE the art and below the
   text, so the reading stays crisp while the picture underneath looks like
   it is genuinely behind glass. */
.tile::before {
  content: ""; position: absolute; inset: 0; z-index: 1; pointer-events: none;
  border-radius: inherit;
  background:
    /* A broad, very low-contrast SHEEN across the face - the whole interior
       of GK's reference plate carries one of these, and it is nothing like a
       hotspot. Kept under 5% so the reading beneath keeps its colour. */
    linear-gradient(128deg,
      rgba(255, 255, 255, 0.045) 0%,
      rgba(255, 255, 255, 0.015) 34%,
      rgba(255, 255, 255, 0.00) 55%,
      rgba(255, 255, 255, 0.02) 82%,
      rgba(255, 255, 255, 0.00) 100%),
    /* and the light falling away towards the far corner */
    radial-gradient(120% 90% at 110% 114%,
      rgba(0, 0, 0, 0.20), transparent 64%);
}
/* The moulded EDGE: a lit rim along the top and left where the light lands,
   a dark one bottom-right where it does not, and a hairline of thickness
   inside both - which is what makes the plastic read as having depth rather
   than being a printed highlight. */
.tile::after {
  content: ""; position: absolute; inset: 0; z-index: 3; pointer-events: none;
  border-radius: inherit;
  /* THE POLISHED EDGE (GK's reference plate, 7 Aug 2026). The frame is what
     makes a slab of glass read as glass: a crisp outer lip, a band of
     thickness a few pixels in, and a second inner edge where that band meets
     the face. Lit along the top and left, dark bottom-right.
     All of it lives on the PERIMETER - the interior stays clear, which is
     what stopped the colours washing out. */
  box-shadow:
    /* the crisp outer lip, right round */
    inset 0 0 0 1px rgba(255, 255, 255, 0.14),
    /* the band of glass thickness */
    inset 0 0 0 4px rgba(255, 255, 255, 0.035),
    /* the inner edge of that band, catching the light top and left */
    inset 0 5px 1px -4px rgba(255, 255, 255, 0.34),
    inset 5px 0 1px -4px rgba(255, 255, 255, 0.20),
    /* and losing it bottom and right */
    inset 0 -5px 1px -4px rgba(0, 0, 0, 0.5),
    inset -5px 0 1px -4px rgba(0, 0, 0, 0.34);
}
.tile:hover { border-color: #39404a; }
.tile:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* Held down: the key sinks, like a real one. */
.tile.is-held {
  transform: scale(0.955) translateY(1px);
  box-shadow:
    0 1px 3px rgba(0, 0, 0, 0.6),
    inset 0 2px 6px rgba(0, 0, 0, 0.55),
    inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.tile.is-held::before { opacity: 0.4; }
.tile.is-busy { opacity: 0.6; }
/* PENDING is not BUSY (GK, 19 Aug 2026). The key has already moved to what
   was asked for and stays fully legible - this only says "not confirmed yet".
   A press must never dim the thing under the owner's finger: that is what
   made a 250 ms switch feel like a second and a half. */
.tile.is-pending { opacity: 0.94; }

/* ONE blue and ONE orange, the app's own (GK, 6 Aug 2026 - a brighter pair
   was tried and rejected: "You changed the colour blue... no"). The accent
   is the water everywhere in this app and #ffb641 is the readout orange; a
   key must not introduce a second shade of either. If a reading needs to
   carry further, that is a job for weight, size or its glow - never a new
   hue. A device with its own Label Colour still overrides both. */
.tile { --on-colour: var(--readout); }
.tile[data-kind="water"] { --readout: var(--accent); }
.tile[data-kind="temp"] { --readout: #ffb641; }

/* The art fills the whole key and everything else floats over it. */
.tile-art { position: absolute; inset: 0; z-index: 0; pointer-events: none; }
.tile-svg { width: 100%; height: 100%; display: block; }

/* The water surface line, and the faint history over it. The surface is
   deliberately DIMMER than the water under it (GK, 6 Aug 2026: "the water
   level surface need to be half transparent, must be dimmer than the water
   level") - the body of water is the reading; the line only says where it
   ends. The fill reaches 0.40 at the surface, so this sits below that. */
.tile-surface { stroke: var(--readout); stroke-width: 1.2; opacity: 0.3; }
/* "a faint line that shows what the level has done in the past period" (GK).
   White rather than the readout colour so it stays legible over the water
   AND over the empty space above it. */
.tile-history {
  fill: none; stroke: rgba(255, 255, 255, 0.25); stroke-width: 1.1;
  stroke-linejoin: round; stroke-linecap: round;
  vector-effect: non-scaling-stroke;
}
/* Dimmed to match the water's surface line exactly (GK, 7 Aug 2026: "The temp
   orange line needs to be dimmer like the water line") - one value for
   "this is where the reading has been", on both kinds of key. */
.tile-trend { stroke: var(--readout); opacity: 0.3; stroke-width: 1.2; }

/* The two wave layers drift in opposite directions at different speeds -
   that parallax is what reads as water rather than a filled box. The path is
   200 wide in a 100 viewBox, so a 100-unit slide is seamless. */
/* The surface is STATIC (GK, 6 Aug 2026: "leave them static"). The wave path
   is still generated wide enough to slide, so motion is a keyframe away if it
   is ever wanted - but no dead rule is left lying about for it. */
/* Nothing on a key animates now, so there is no reduced-motion exception to
   make - the level, the number and the history say everything standing
   still. */

/* The reading on a sensor key: the app's readout face and colour, sized to
   the key rather than the page. */
.tile-value {
  position: absolute; left: 6px; right: 6px; z-index: 2;
  /* About two thirds up (GK, 6 Aug 2026: "make the on / off symbol, depths
     and temps about 2/3 up the tile, so there room for the name at the
     bottom" - superseding the same day's dead-centre). Bottom at 65% puts
     the lower edge there; the half-height nudge back down centres the number
     ON that line, which holds at every tile size. */
  bottom: 65%; transform: translateY(50%);
  font-family: "Roboto", system-ui, sans-serif;
  font-weight: 700; font-size: 34px; line-height: 1;
  color: var(--readout); text-align: center;
  font-variant-numeric: tabular-nums;
  /* A SHARP OUTLINE, not a glow (GK, 7 Aug 2026: "You have a glow behind the
     depth and temp values. that may make it dimmer, make it sharp outlines").
     The coloured glow bled the digit's own colour outwards and softened its
     edge, which read as dimmer rather than brighter. A stroke painted BEHIND
     the fill (paint-order) gives a crisp dark edge that lifts the number off
     the water or the trend while leaving the glyph at full weight and full
     colour. */
  paint-order: stroke fill;
  /* Thin. A fat dark stroke round every digit muddies the colour it is meant
     to be protecting - the blue stopped matching the switcher pill's border
     even though it is the SAME --accent (GK, 7 Aug 2026: "The blue you use
     should the be same blue that you use for the pill"). Enough edge to lift
     the number off the water, not enough to tint it. */
  -webkit-text-stroke: 1.4px rgba(0, 0, 0, 0.5);
  text-shadow: none;
  margin: 0; pointer-events: none;
}
.tile-unit { font-size: 14px; font-weight: 500; margin-left: 2px; }
.tile-idle { font-size: 22px; color: var(--text-dim); }

/* An output or input key carries a mark instead of a number. */
.tile-glyph {
  /* Three fifths up (GK, 7 Aug 2026), a touch lower than a sensor's reading
     at two thirds. They are deliberately not identical: a wide symbol and a
     line of digits do not optically centre at the same height, and the
     symbol carries more ink. */
  position: absolute; left: 0; right: 0; z-index: 2;
  bottom: 60%; transform: translateY(50%);
  display: flex; justify-content: center;
  /* Dimmer than --text-dim now the symbol is 140% (GK, 7 Aug 2026: "the
     on/off icon should be a dimmer colour grey when it is not on"). At this
     size the old value made an OFF key shout as loudly as a lit one; unlit
     should sit back and let the lit ones carry the wall. */
  color: #3b414a;
  pointer-events: none; transition: color .15s, filter .15s;
}
/* Sized by eye over a few passes (GK, 7 Aug 2026): 140% of the first
   cut, then 80% of that. Every variant below is scaled with it, so the
   S/M/L ladder still steps evenly. */
.tile-mark { width: 38px; height: 38px; }
.tile[data-kind="input"] .tile-glyph { color: var(--accent); opacity: 0.75; }

/* ON is the sensor-readout orange the output toggles already use (GK,
   23 Jul 2026) - one value for "this output is on", app-wide. A lit key
   glows from the inside, which is what a Stream Deck key does. */
.tile[data-kind="output"][data-on="true"] {
  border-color: color-mix(in srgb, var(--on-colour) 50%, transparent);
  background:
    /* The lamp sits BEHIND the symbol and blooms from it (GK, 7 Aug 2026:
       "make it glow behind more. Make the symbol really glow") - centred on
       the mark at 35% down rather than washing up from the bottom edge, so
       the key looks lit from the thing that is on.
       A ring - transparent in the middle, brightest further out - was tried
       to give the glyph a harder edge and rejected: "nah, that is nasty". A
       lamp glows from its centre; hollowing it out reads as a halo round
       nothing. */
    radial-gradient(58% 46% at 50% 35%,
      color-mix(in srgb, var(--on-colour) 46%, transparent), transparent 72%),
    radial-gradient(125% 95% at 50% 118%,
      color-mix(in srgb, var(--on-colour) 24%, transparent), transparent 70%),
    radial-gradient(115% 80% at 50% -10%, #23201a 0%, #16150f 55%, #100f0b 100%);
  box-shadow:
    0 6px 16px rgba(0, 0, 0, 0.55),
    0 0 30px color-mix(in srgb, var(--on-colour) 26%, transparent),
    inset 0 1px 0 rgba(255, 255, 255, 0.12),
    inset 0 -2px 3px rgba(0, 0, 0, 0.5),
    inset 0 0 0 1px color-mix(in srgb, var(--on-colour) 8%, transparent);
}
/* And the symbol itself blooms. Stacked drop-shadows rather than one big
   one: a tight core, a mid halo and a wide wash read as a light source,
   where a single large blur just looks smudged. The glyph keeps its own
   colour - GK considered white and preferred the device's. */
.tile[data-kind="output"][data-on="true"] .tile-glyph {
  color: var(--on-colour);
  filter:
    drop-shadow(0 0 3px color-mix(in srgb, var(--on-colour) 90%, transparent))
    drop-shadow(0 0 9px color-mix(in srgb, var(--on-colour) 70%, transparent))
    drop-shadow(0 0 22px color-mix(in srgb, var(--on-colour) 45%, transparent));
}
.tile[data-kind="output"][data-on="true"] .tile-name { color: var(--text); }

/* Disabled at the Controller: the key is visibly inert rather than lying
   about a press it cannot make. */
.tile[data-dead="true"] { opacity: 0.45; }
.tile[data-dead="true"] .tile-glyph { color: #2c3138; }

.tile-label {
  position: relative; z-index: 2; display: flex; flex-direction: column;
  align-items: center; text-align: center;
  gap: 1px; min-width: 0; pointer-events: none;
}
.tile-name {
  max-width: 100%; font-size: 12px; font-weight: 600; color: var(--text-secondary);
  line-height: 1.25; overflow: hidden; text-overflow: ellipsis;
  white-space: nowrap; text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8);
}
.tile-sub {
  max-width: 100%; font-size: 10px; color: var(--text-dim); line-height: 1.2;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

/* The way to the device page, on every key (GK: "a small link at the top
   right of each device that takes you to the device page"). */
.tile-link {
  position: absolute; top: 4px; right: 4px; z-index: 4;
  width: 24px; height: 24px; display: flex;
  align-items: center; justify-content: center;
  color: var(--text-dim); border-radius: 7px;
  opacity: 0.55; transition: opacity .15s, color .15s, background .15s;
}
.tile-link svg { width: 15px; height: 15px; }
.tile-link:hover {
  opacity: 1; color: var(--text); background: rgba(255, 255, 255, 0.08);
}

/* A press acknowledges itself even when the thing it drives takes a moment
   (or does nothing, as on a sensor) - the key never feels dead. */
.tile-flash {
  position: absolute; inset: 0; z-index: 3; pointer-events: none; opacity: 0;
  background: radial-gradient(circle at 50% 60%, rgba(255, 255, 255, 0.22), transparent 65%);
}
.tile.is-flash .tile-flash { animation: tile-flash .5s ease-out; }
@keyframes tile-flash {
  from { opacity: 1; }
  to { opacity: 0; }
}

/* The turn-on-for panel over the wall. The panel itself is the SHARED
   .out-pop (static/timedon.js); this only gives it somewhere to sit, because
   a 116px key cannot hold it. */
.dash-pop-shade {
  position: fixed; inset: 0; z-index: 50;
  background: rgba(0, 0, 0, 0.55);
  display: flex; align-items: center; justify-content: center; padding: 16px;
}
.dash-pop-frame {
  background: #14171c; border: 1px solid var(--border);
  border-radius: 14px; padding: 14px; max-width: 420px; width: 100%;
  box-shadow: 0 18px 40px rgba(0, 0, 0, 0.55);
}
.dash-pop-for {
  font-size: 12px; color: var(--text-dim); margin-bottom: 6px;
}
.dash-pop-frame .out-pop {
  position: static; border: none; background: transparent;
  padding: 0; box-shadow: none; width: auto;
}

/* --- Choosing what goes on the wall --------------------------------------- */
/* The same shape as the AI Setup and Sharing pickers: a row per device, its
   channels as chips. Ticking applies at once, like every other save here. */
.dash-pick-row {
  display: flex; align-items: flex-start; gap: 12px;
  padding: 10px 0; border-bottom: 1px solid var(--border);
}
.dash-pick-row:last-child { border-bottom: none; }
.dash-pick-name {
  flex: none; width: 160px; display: flex; flex-direction: column; gap: 2px;
  color: var(--text); font-weight: 600;
}
.dash-pick-type { font-size: 12px; font-weight: 400; color: var(--text-dim); }
.dash-chips { display: flex; flex-wrap: wrap; gap: 6px; flex: 1; min-width: 0; }
.dash-chip {
  display: inline-flex; align-items: baseline; gap: 6px;
  background: transparent; color: var(--text-secondary);
  border: 1px solid var(--border); border-radius: 10px;
  padding: 5px 12px; font-size: 13px; font-family: inherit;
  cursor: pointer; transition: color .15s, border-color .15s, background .15s;
}
.dash-chip:hover { color: var(--text); border-color: #3a414c; }
.dash-chip.is-on {
  color: var(--text); border-color: var(--accent);
  background: rgba(46, 107, 255, 0.14);
}
.dash-chip-kind {
  font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase;
  color: var(--text-dim);
}
.dash-chip.is-on .dash-chip-kind { color: var(--accent); }

/* --- The + key, dragging, and the tile size (GK, 6 Aug 2026) -------------- */

/* Tile size is a WALL-level choice - a deck's keys are all one size. The
   grid's minimum column is the only thing that changes; everything inside a
   key is sized from the key, so nothing needs a second set of rules. */
.dash-wall[data-size="small"] { grid-template-columns: repeat(auto-fill, minmax(88px, 1fr)); gap: 10px; }
.dash-wall[data-size="large"] { grid-template-columns: repeat(auto-fill, minmax(164px, 1fr)); gap: 14px; }
.dash-wall[data-size="small"] .tile-value { font-size: 25px; }
.dash-wall[data-size="small"] .tile-mark { width: 29px; height: 29px; }
.dash-wall[data-size="small"] .tile-sub { display: none; }
.dash-wall[data-size="large"] .tile-value { font-size: 46px; }
.dash-wall[data-size="large"] .tile-unit { font-size: 19px; }
.dash-wall[data-size="large"] .tile-mark { width: 51px; height: 51px; }
.dash-wall[data-size="large"] .tile-name { font-size: 14px; }
.dash-wall[data-size="large"] .tile-sub { font-size: 11px; }

.dash-sizes { display: flex; gap: 6px; flex: none; }

/* The faded + key on the end: the one door to choosing what is on the wall.
   A key like the others so the row still reads as a deck, and quiet enough
   that it never competes with a real one. */
.tile-add {
  align-items: center; justify-content: center; gap: 6px;
  position: relative;
  border-style: dashed; border-color: #2e333b; background: transparent;
  color: var(--text-dim); opacity: 0.55; box-shadow: none;
  font-family: inherit; transition: opacity .15s, color .15s, border-color .15s;
}
.tile-add:hover {
  opacity: 1; color: var(--text-secondary); border-color: #3a414c;
}
.tile-add-word { font-size: 12px; font-weight: 600; }

/* Dragging a key into place. It stays in the flow, so the rest of the wall
   shuffles live around it - the key lifts, it does not leave a hole. */
.tile.is-dragging {
  transform: scale(1.06); z-index: 5; opacity: 0.92;
  border-color: var(--accent);
  box-shadow: 0 10px 26px rgba(0, 0, 0, 0.6), 0 0 0 1px var(--accent-glow);
  cursor: grabbing;
}
/* While arranging, the other keys stop reacting to the pointer passing over
   them - a hover highlight under a dragged key reads as a drop refusal. */
.dash-wall.is-arranging .tile:not(.is-dragging) { transition: transform .16s ease; }
.dash-wall.is-arranging .tile:not(.is-dragging):hover { border-color: #262a31; }
.dash-wall.is-arranging .tile-link { opacity: 0; }

/* --- Several dashboards, named (GK, 6 Aug 2026) --------------------------- */
/* "Should be able to have multiple dashboards, name them, and share them."
   The name IS the page heading and edits in place - the instant-rename
   pattern the device and automation names already use. */
.dash-title {
  flex: 1; min-width: 0;
  background: transparent; border: 1px solid transparent; border-radius: 8px;
  color: var(--text); font-family: inherit; font-size: 20px; font-weight: 700;
  letter-spacing: -0.01em; padding: 2px 8px; margin: 0 0 0 -8px;
  transition: border-color .15s, background .15s;
}
.dash-title:hover { border-color: var(--border); }
.dash-title:focus {
  outline: none; border-color: var(--accent);
  background: rgba(46, 107, 255, 0.06);
}
.dash-tools { display: flex; align-items: center; gap: 10px; flex: none; }
.dash-remove { color: var(--text-dim); }
.dash-remove:hover { color: var(--error); border-color: rgba(224, 82, 82, 0.5); }

/* The switcher: every wall this system has, and the way to make another. */
.dash-switch {
  display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
  margin: 10px 0 0;
}
.dash-switch .span-chip { text-decoration: none; }
.dash-switch-count {
  margin-left: 7px; font-size: 11px; color: var(--text-dim);
  font-variant-numeric: tabular-nums;
}
.dash-switch .span-chip.is-on .dash-switch-count { color: var(--accent); }
.dash-new { color: var(--text-dim); }

/* --- Edit mode on the wall (GK, 7 Aug 2026) ------------------------------- */
/* Two jobs in one switch: it hands the drag gesture to the keys, and it puts
   the size and Remove controls on screen only while they are wanted. */
.dash-edit.is-on {
  color: var(--text); border-color: var(--accent);
  background: rgba(46, 107, 255, 0.14);
}
.dash-edit-tools { display: flex; align-items: center; gap: 10px; }

/* THE GESTURE. This is the whole reason a phone could not reorder the wall:
   until the keys claim the touch, the page scroller owns it. Claimed only
   while arranging, so outside this mode the wall still scrolls normally. */
.dash-wall.is-edit .tile { touch-action: none; cursor: grab; }
.dash-wall.is-edit .tile-link { display: none; }

/* A gentle rock, so it is obvious the keys are loose - each one offset from
   its neighbours by its position, or a wall moving in lockstep looks like
   one object rather than a set of them. Deliberately a slow, small tilt
   rather than an imitation of anyone's house style. */
@keyframes tile-rock {
  0%, 100% { transform: rotate(-0.55deg); }
  50% { transform: rotate(0.55deg); }
}
.dash-wall.is-edit .tile:not(.is-dragging) {
  animation: tile-rock 1.1s ease-in-out infinite;
}
.dash-wall.is-edit .tile:nth-child(2n) { animation-delay: -0.22s; }
.dash-wall.is-edit .tile:nth-child(3n) { animation-delay: -0.46s; }
.dash-wall.is-edit .tile:nth-child(5n) { animation-delay: -0.68s; }
.dash-wall.is-edit .tile-add { animation: none; opacity: 0.3; }
@media (prefers-reduced-motion: reduce) {
  .dash-wall.is-edit .tile:not(.is-dragging) { animation: none; }
  /* Still say the keys are loose, without moving them. */
  .dash-wall.is-edit .tile { border-color: var(--accent); }
}

@media (max-width: 640px) {
  /* A phone is too narrow for an auto-fill minimum to separate the sizes -
     small and medium both landed on the same column count, so only the text
     changed (GK, 6 Aug 2026). Fix the COLUMN COUNT per size instead.
     GK's ladder (6 Aug 2026): "four should be small, 3 medium, 2 large".
     These columns are 1fr, so they STRETCH to fill - which is why the first
     cut's three-across came out BIGGER on a wide phone than on the desktop.
     A fixed count per size keeps every phone honest. */
  .dash-wall { grid-template-columns: repeat(3, 1fr); gap: 9px; }
  .dash-wall[data-size="small"] { grid-template-columns: repeat(4, 1fr); gap: 8px; }
  .dash-wall[data-size="medium"] { grid-template-columns: repeat(3, 1fr); gap: 9px; }
  .dash-wall[data-size="large"] { grid-template-columns: repeat(2, 1fr); gap: 12px; }
  /* Everything on a key reads a size larger on a phone (GK, 7 Aug 2026:
     "Need to make it a bit bigger on the mobile"). The column counts are
     GK's own 4/3/2, so the keys themselves are fixed - what grows is what is
     ON them, which is what was actually hard to read at arm's length. */
  .tile-value { font-size: 30px; }
  .tile-name { font-size: 13px; }
  .tile-sub { font-size: 11px; }
  .tile-mark { width: 42px; height: 42px; }
  .tile-unit { font-size: 13px; }

  /* At four across there is no room for a second line of label. */
  .dash-wall[data-size="small"] .tile-name { font-size: 12px; }
  .dash-wall[data-size="small"] .tile { padding: 7px; border-radius: 14px; }
  .dash-wall[data-size="small"] .tile-sub { display: none; }
  .dash-wall[data-size="small"] .tile-value { font-size: 21px; }
  .dash-wall[data-size="small"] .tile-unit { font-size: 11px; }
  .dash-wall[data-size="small"] .tile-mark { width: 30px; height: 30px; }
  /* The corner arrow is a fingertip target on a small key - shrink it so it
     does not sit over the art. */
  .dash-wall[data-size="small"] .tile-link {
    width: 20px; height: 20px; top: 2px; right: 2px;
  }
  .dash-wall[data-size="medium"] .tile-value { font-size: 30px; }
  .dash-wall[data-size="large"] .tile-value { font-size: 44px; }
  .dash-wall[data-size="large"] .tile-unit { font-size: 18px; }
  .dash-wall[data-size="large"] .tile-mark { width: 58px; height: 58px; }
  .dash-wall[data-size="large"] .tile-name { font-size: 15px; }

  /* NOTHING on this page may be wider than the phone (GK, 6 Aug 2026: "There
     is a grey line on the Right Side of the android screen"). A row that
     cannot fit wraps rather than pushing the page wide and leaving a strip
     of page background down the edge. */
  .dash-head { flex-wrap: wrap; gap: 8px; }
  .dash-title { flex: 1 1 100%; max-width: 100%; }
  .dash-tools { flex-wrap: wrap; }
  .dash-switch { max-width: 100%; }
  .dash-switch .span-chip { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .tile-value { font-size: 26px; }
  .dash-pick-row { flex-direction: column; gap: 8px; }
  .dash-pick-name { width: auto; flex-direction: row; align-items: baseline; gap: 8px; }
}

/* --- Touch devices keep the platform's own scrollbars (GK, 6 Aug 2026) ----- */
/* "There is a grey line on the Right Side of the android screen looks aweful."
   Two app-wide rules cause it together: `scrollbar-gutter: stable` reserves a
   strip on every page, and the themed scrollbar colours make Chrome use a
   CLASSIC scrollbar rather than the overlay one a phone would normally draw.
   On a desktop that pairing is right - it stops the layout shifting between a
   page that scrolls and one that does not. On a touch screen it buys nothing
   (there is no pointer to hit a scrollbar with) and costs a permanent grey
   track down the edge.
   So on coarse-pointer devices, hand both back to the platform. Deliberately
   scoped to touch: the desktop behaviour the project settled on is untouched. */
@media (hover: none) and (pointer: coarse) {
  html {
    scrollbar-gutter: auto;
    scrollbar-color: auto;
    scrollbar-width: auto;
  }
  html::-webkit-scrollbar,
  body::-webkit-scrollbar { width: auto; height: auto; }
}

/* --- VC: the Virtual Controller (GK, 7 Aug 2026) --------------------------- */
/* The unit is the Controller's OWN PHOTOGRAPH with the live screen laid over
   its glass (GK: "use the actual photo of the controller and just overlay the
   screen"). The artwork is built from the manual's straight-on studio shot by
   Tools-side processing: the annotation arrow inpainted out, the LCD blanked
   to a lit panel so nothing of the photographed screen survives under the
   overlay.

   EVERY overlay below is positioned in PER CENT of the photo, measured off
   the photo itself - the screen rect was fitted against a real framebuffer
   capture (the header rule spans all 128 columns, which fixes the scale). So
   a sharper photo swaps in by replacing img/controller.png and these numbers,
   and nothing else in the app moves. */

/* âš  A DELIBERATE, DOCUMENTED EXCEPTION to the one-layout-container rule,
   made at GK's direction ("fill it properly") and scoped to this one card.
   The screen's scale steps at WHOLE NUMBERS, and the step that actually
   fills this glass - 3 across, 4 down - needs the unit about 1080px wide.
   The 960px content column cannot carry that, and every smaller width
   leaves a band of dead panel. So the VC stage, and ONLY the VC stage,
   breaks the column. Nothing else on the page does. */
/* NOT a card (GK, 8 Aug 2026: "I think the Virtual controller should be
   straight on the black background, not in a box. Will make it look
   better"). The unit is a photographed object with its own edges, and a
   card drew a second, competing rectangle around it. This keeps only the
   centring and the page's 16px rhythm; the surface, border, radius and
   padding a .form-card brought with it are gone.
   (The width is NOT set here - it lives on .vc-body below, at 500px, so
   the exception noted above is historical: at 500px nothing breaks the
   960px column any more.) */
.vc-stage {
  display: flex;
  flex-direction: column;
  align-items: center;
  margin: 16px 0;            /* the app's one card rhythm, kept */
}

.vc-body {
  position: relative;
  width: 100%;
  /* 450px, GK's own number (7 Aug 2026), after a 1080px unit built to
     chase a perfect fill turned out to be enormous on the page.
     âš  WHAT THIS SIZE COSTS, so nobody "fixes" it by accident: the screen's
     scale steps at WHOLE NUMBERS - that is what keeps every pixel the same
     size and the font reading as the Controller's - and at 450px the glass
     is only 190x124, so the only scale that fits is 1x and the screen is
     128x64.
     âš  THE FIT CHANGES IN STEPS, NOT SMOOTHLY, so a nearby number is not a
     small change. The steps (with the 6px inset):
        366-560px  1x1  128x64     500 is here
        561-702px  1x2  128x128    avoid - text at double height
        703-817px  2x2  256x128
        818-1039px 2x3  256x192    the 6px margin top and bottom
       1040-1075px 3x3  384x192
       1076px+     3x4  384x256    fills best, but enormous on the page  */
  max-width: 500px;
  touch-action: none;                /* the wheel/drag belong to the unit */
  user-select: none;
  line-height: 0;                    /* no descender gap under the photo */
}

.vc-case { width: 100%; height: auto; display: block; }

/* The LCD, over the photo's glass. This container is the GLASS itself
   (GK, 7 Aug 2026: "make it fill the glass, I don't mind the bezel") - not
   the measured active area, which left a visible margin of dead panel.

   The canvas inside holds the screen at its true 128x64 and is scaled by a
   WHOLE NUMBER on EACH AXIS - "get the screen right and then resize, do
   not resize using the pixels". Whole numbers are what keep every pixel
   identical, which is what makes the font read as the Controller's; the
   two axes are allowed to differ because the glass is not 2:1 and forcing
   one number is what left the margin.

   Independent scales are also TRUER to the hardware, not a compromise: the
   real panel's pixels are about 1.22x taller than they are wide, so 2x3 is
   closer to it than a square 2x2 was. */
.vc-screen-area {
  --vc-inset: 6px;                   /* GK: keep it off the edges ~6px */
  position: absolute;
  left: 24.417%; top: 21.325%; width: 38.0%; height: 48.142%;
  padding: var(--vc-inset);
  display: flex;
  align-items: center;
  justify-content: center;
}

/* The glass is clickable: touching a line puts the selector on it
   (GK, 8 Aug 2026), so it invites the press like anything else that
   acts. */
.vc-screen { cursor: pointer; }
.vc-screen {
  --vc-ink: #1f2e64;                 /* read by vc.js - colour lives here */
  --vc-panel: #7ea1c2;               /* both sampled from the photo itself */
  /* âš  SMOOTHED, NOT PIXELATED, and that is the whole point (GK, 7 Aug
     2026: "dont add more pixels, anti alias").

     The screen is drawn at its true 128x64 and scaled up by a fraction to
     fill the glass. Nearest-neighbour resolves a fraction by DUPLICATING
     pixels - some Controller pixels get two screen pixels, their
     neighbours get one - and that unevenness lands in the letter stems,
     which is what made the font read wrong. Filtering resolves the same
     fraction by shading the edges instead, so every pixel is treated
     identically. Slightly soft, evenly soft.
     `auto` IS the filtered default; it is written out because the absence
     of a rule here would look like nobody had decided. */
  image-rendering: auto;
  display: block;
}

/* An unreadable screen is dimmed rather than left showing the last good
   dump: a stale LCD is a Controller that is not there. Sits over the whole
   glass, not just the active area, so the whole panel reads as dead. */
.vc-glass-veil {
  position: absolute;
  left: 24.417%; top: 21.325%; width: 38.0%; height: 48.142%;
  background: rgba(74, 88, 102, 0.62);
  border-radius: 5px;
}

/* The hotspots are INVISIBLE - the photo already draws the parts. They only
   light up on hover and focus, so the unit stays a photograph. */
.vc-key, .vc-knob, .vc-rot {
  /* âš  A HOLD HERE IS A LONG PRESS ON THE CONTROLLER, not a browser gesture
     (GK, 7 Aug 2026). On a phone, holding a control normally raises the
     text-selection callout / context menu and the press is lost - which
     would put HALF the firmware's UI out of reach, since long, short-long
     and double-long are real gestures the on-screen arrows advertise.
     `touch-action: none` also stops the hold turning into a page scroll,
     and the tap highlight is killed so the unit does not flash blue. */
  touch-action: none;
  -webkit-touch-callout: none;
  -webkit-user-select: none;
  user-select: none;
  -webkit-tap-highlight-color: transparent;
}

.vc-key, .vc-knob {
  position: absolute;
  border: none;
  background: transparent;
  padding: 0;
  cursor: pointer;
  border-radius: 50%;
  transition: background 90ms linear, box-shadow 90ms linear;
}

/* Measured off the artwork: the photographed buttons are 128px across,
   centred at y=313. The hotspots sit exactly on them, and should stay
   there - GK's resizing was about the rotary arrows, "not the face
   buttons". */
.vc-key-left  { left: 7.333%;  top: 40.388%; width: 10.667%; height: 20.679%; }
.vc-key-right { left: 69.083%; top: 40.388%; width: 10.667%; height: 20.679%; }
.vc-knob {
  left: 86.0%; top: 10.016%; width: 13.25%; height: 26.01%;
  border-radius: 14%;
  cursor: ns-resize;
}

/* Up and down sit DIRECTLY UNDER the rotary (GK, 7 Aug 2026) - they do
   what the knob does, so they belong with it rather than in the strip of
   page controls. Same left edge and width as the knob, stacked beneath it.
   They stand on the card background rather than the case, so they are
   dressed light-on-dark like the rest of the app, not like a case part. */
.vc-rot {
  position: absolute;
  /* Trimmed twice on GK's eye (7 Aug 2026), 10px each time: 66px across
     when they first went on the unit, then 56, now 46 in the 500px unit.
     The left edge moves in with each cut so they stay on the knob's own
     centre line of 92.625%. */
  /* ⚠ ANCHORED BY THE CENTRE, CAPPED IN PIXELS (GK, 4 Sep 2026: "make the
     up and down arrow boxes on the side max size equivalent to how big they
     are at 600px controller width").
     
     They used to be pure per cent, which is right for a hotspot over a
     photograph but wrong for these two: they are the only DRAWN control on
     the unit, and at a presentation size they grew into two great slabs
     hanging off the side. So they stop growing at the size they reach on a
     600px unit - 67.5 x 74.04px, which is 11.25% x 23.922% of that unit and
     its 309.5px height.
     
     The centre line, not the left edge, is what is pinned - 92.625% across
     is the knob's own centre, and 87.000% + half the width is exactly that.
     Below the cap the boxes are therefore in precisely the position GK's eye
     put them; above it they stay on the same lines and simply stop. */
  left: 92.625%;
  width: 11.250%;
  max-width: 67.5px;
  /* 25px taller each on GK's eye (7 Aug 2026) - the BUTTON's own height,
     not its position: 21.7px becomes 46.7px in the 500px unit. The pair's
     top stays put, so they grow downward and keep the tuck under the knob
     that was set just before. */
  height: 23.922%;
  max-height: 74.04px;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  font-size: 10px;
  line-height: 1;
  color: var(--text-secondary);
  background: rgba(255, 255, 255, 0.06);
  border: 1px solid rgba(255, 255, 255, 0.16);
  border-radius: 5px;
  cursor: pointer;
  transition: background 90ms linear, color 90ms linear;
}
/* Started at the midpoint between the bottom of the rotary (36.03%) and the
   bottom of the case (98.55%) - 67.29% - then lifted 15px twice on GK's
   eye (5.82% of the unit's height each time), so the pair tucks up under
   the knob it belongs to rather than floating below it. */
/* Lifted a further 13px on GK's eye (8 Aug 2026) - 5.04% of the unit's
   257.9px height at its 500px width. */
/* ⚠ THE PAIR IS ANCHORED, NOT EACH BOX (GK, 4 Sep 2026: "can you make the
   boxes stay together with the same gaps as original").
   
   Capping the size while pinning each box's own centre in per cent was
   wrong: the boxes stopped growing but their centres kept spreading, so by
   a 1800px unit they sat 163px apart instead of 5px. What has to hold
   still is the GAP BETWEEN THEM.
   
   So both hang off ONE line and it is the TOP one, 41.410% - because that
   is the anchor the original was tuned around: "the pair's top stays put,
   so they grow downward and keep the tuck under the knob". Anchoring their
   centre instead would have been my invention, and would have let them
   drift down the case as the unit grew.
   
   The gap is expressed as a percentage of the BOX's height, not the
   unit's. A translate percentage resolves against the element's own box,
   so 6.753% is the original 5px gap at the 74.04px capped height - it
   scales with the box below the cap and freezes with it above. Measured:
   5px at 600px, 5px at 4000px, and correctly 3.33px at 400px. */
.vc-rot      { top: 41.410%; }
.vc-rot-up   { transform: translateX(-50%); }
.vc-rot-down { transform: translate(-50%, calc(100% + 6.753%)); }
.vc-rot:hover { background: rgba(46, 107, 255, 0.28); color: var(--text); }
.vc-rot:active { background: rgba(46, 107, 255, 0.45); }
.vc-rot:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

.vc-key:hover, .vc-knob:hover { background: rgba(46, 107, 255, 0.16); }
.vc-key:focus-visible, .vc-knob:focus-visible {
  outline: 2px solid var(--accent); outline-offset: 2px;
}

/* Pressed reads as pressed IN - the tell that the press was taken, before
   the Controller has answered. */
.vc-key.vc-pressed, .vc-knob.vc-pressed {
  background: rgba(0, 0, 0, 0.22);
  box-shadow: inset 0 2px 7px rgba(0, 0, 0, 0.45);
}

/* All that is left under the unit is the message line - where an
   honest refusal goes (Serial Control off, or the port busy). It is
   centred with the unit rather than left-aligned to a card that is no
   longer there. */
.vc-controls {
  display: flex; flex-wrap: wrap; justify-content: center;
  align-items: center; gap: 10px;
  margin-top: 14px;
  width: 100%;
  line-height: normal;
  text-align: center;
}
.vc-msg { margin: 0; }
.vc-msg-error { color: var(--error); }
/* The notice tier (GK, 26 Aug 2026): the no-USB line is information, not
   a fault - it wears the attention orange, same as .vc-warn. */
.vc-msg-warn { color: var(--readout); }
.vc-warn { color: var(--readout); }
/* The no-port note stands alone now that the page has no intro card
   (GK, 8 Aug 2026) - centred with the unit, and given the room a card
   used to give it. */
.vc-noport { max-width: 500px; margin: 16px auto 0; text-align: center; }

@media (max-width: 640px) {
  .vc-body { max-width: 100%; }
}

/* Dismiss on a notice row (GK, 7 Aug 2026) - sits at the end of the row,
   quiet until wanted, because the row is there to be READ first. */
.notice-dismiss { margin-left: auto; padding: 4px 10px; font-size: 12px; }
.notice-error { color: var(--error); margin-top: 10px; }

/* â”€â”€ The alarm banner (GK, 8 Aug 2026) â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
   "can we push that to alarm on the browsers? Like audiable alert and
   notice at the top of the Mobile screen?"

   Fixed to the top of the viewport rather than placed in the page: an
   alarm must be visible wherever the owner has scrolled to, and on a
   phone that is the only way it is seen at all. It is the layer that
   needs no permission and no audio gesture, so it is the one that always
   works - the siren and the phone notification can both be unavailable.

   Amber is the app's own alarm colour (#ffb641, the same value
   .notice-tag-alarm and the sensor readout use) - not a new one. */
.alarm-banner {
  position: fixed;
  top: 0; left: 0; right: 0;
  z-index: 900;
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 10px 14px;
  background: #2a1f0d;
  border-bottom: 1px solid rgba(255, 182, 65, 0.55);
  box-shadow: 0 2px 14px rgba(0, 0, 0, 0.45);
  font-size: 15px;                 /* body text is body text */
  color: var(--text);
}
.alarm-banner-tag {
  flex: none;
  font-size: 13px;
  font-weight: 700;
  color: var(--readout);
  border: 1px solid rgba(255, 182, 65, 0.55);
  border-radius: 6px;
  padding: 2px 8px;
  white-space: nowrap;
}
.alarm-banner-msg { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.alarm-banner-more { flex: none; font-size: 12px; color: var(--text-dim); }
.alarm-banner-view {
  flex: none;
  color: var(--readout);
  text-decoration: none;
  font-size: 14px;
  padding: 4px 8px;
}
.alarm-banner-view:hover { text-decoration: underline; }
.alarm-banner-silence,
.alarm-banner-close {
  flex: none;
  background: transparent;
  border: 1px solid rgba(255, 182, 65, 0.4);
  color: var(--readout);
  border-radius: 6px;
  font-family: inherit;
  font-size: 14px;
  padding: 4px 10px;
  cursor: pointer;
}
.alarm-banner-close { padding: 2px 9px; font-size: 17px; line-height: 1.1; }
.alarm-banner-silence:hover,
.alarm-banner-close:hover { background: rgba(255, 182, 65, 0.12); }

/* On a phone the message earns the width: the tier tag and the message sit
   on the first line and the controls wrap under them, rather than the
   message being squeezed to nothing by three buttons. */
@media (max-width: 640px) {
  .alarm-banner { flex-wrap: wrap; gap: 8px; padding: 9px 12px; }
  .alarm-banner-msg { flex: 1 1 100%; order: 3; }
  .alarm-banner-close { margin-left: auto; }
}

/* --- The Map (GK, 10 Aug 2026) -------------------------------------------
   The owner's system on the ground. The map IS the page, so the controls
   float over it the way every map's own controls do, rather than stacking
   above it and stealing its height. Everything here is scoped under
   .map-page or a .map- class: Leaflet ships its own stylesheet and the two
   must not argue. */

.map-head {
  display: flex; align-items: center; justify-content: space-between;
  flex-wrap: wrap; gap: 12px; margin-bottom: 12px;
}
.map-tools { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
.map-layers { display: flex; gap: 6px; flex-wrap: wrap; }

/* THE STACKED HEAD (GK, 10 Aug 2026): "The surfaces should be below the name.
   Below the surfaces should be the layers etc." One bar per idea, each with a
   quiet label so a row of chips says what it is a row OF. The label takes the
   footnote's own size and colour rather than a new one - body text is one
   system. */
.map-bar {
  display: flex; align-items: baseline; flex-wrap: wrap;
  gap: 6px 10px; margin: 0 0 10px;
}
.map-bar-label {
  font-size: 13px; color: var(--text-secondary);
  min-width: 62px;
}
.map-surfaces { display: flex; gap: 6px; flex-wrap: wrap; }
/* The name, editable in place while Edit is on. Sized and weighted as the
   heading it replaces, so turning Edit on does not make the page jump. */
.map-title-input {
  font-size: 20px; font-weight: 600;
  padding: 0 6px; max-width: 100%; min-width: 220px;
}

/* LAYERS (GK, 10 Aug 2026): "the layers can then be toggled on and off".
   A chip per layer, carrying its own colour as a dot so the bar reads as a
   key to the drawing. A layer that is OFF keeps its place and its name and
   simply goes quiet - it is hidden, not gone, and it should look it. */
.map-layer-chips { display: flex; gap: 6px; flex-wrap: wrap; }
.map-layer-chip { display: inline-flex; align-items: center; gap: 7px; }
.map-layer-chip:not(.is-on) { opacity: 0.55; }
.map-layer-dot {
  width: 10px; height: 10px; border-radius: 50%;
  flex: none;
}
.map-layer-chip:not(.is-on) .map-layer-dot {
  /* Hollow when hidden, so the state reads without relying on colour alone. */
  background: transparent !important;
  box-shadow: inset 0 0 0 2px var(--text-secondary);
}

/* One row per layer in the Edit drawer: what it is called, what colour it
   draws in, and where it sits in the order. */
.map-layer-rows { display: flex; flex-direction: column; gap: 10px; }
.map-layer-row {
  display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
  padding-bottom: 10px; border-bottom: 1px solid var(--border);
}
.map-layer-row:last-child { border-bottom: 0; padding-bottom: 0; }
.map-layer-name { flex: 1 1 130px; min-width: 0; }
.map-layer-fixed { flex: 1 1 130px; font-size: 15px; color: var(--text-secondary); }
.map-layer-swatches { flex: 1 1 100%; }
.map-layer-tools { display: flex; align-items: center; gap: 6px; }
/* Square, so an arrow reads as a nudge rather than a word. */
.map-layer-move { min-width: 34px; padding-left: 8px; padding-right: 8px; }
.map-layer-move[disabled] { opacity: 0.35; cursor: default; }
.map-layer-add { display: flex; gap: 8px; margin-top: 12px; }
.map-layer-add .input-basic { flex: 1 1 auto; min-width: 0; }

/* DRAWING (GK, 10 Aug 2026: "draw on the layers with lines etc").
   The bar sits along the bottom of the map while a shape is being built. It
   leads with WHICH LAYER is taking the ink, because that is the one thing you
   must not be wrong about while clicking a map. */
.map-draw-bar {
  position: absolute; left: 12px; right: 12px; bottom: 12px; z-index: 500;
  display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px;
  padding: 10px 12px;
  background: var(--panel); border: 1px solid var(--border);
  border-radius: var(--radius-card);
  box-shadow: 0 6px 24px rgb(0 0 0 / 35%);
}
.map-draw-on { font-size: 15px; }
.map-draw-kinds { display: flex; gap: 6px; }
.map-draw-label { flex: 1 1 160px; min-width: 0; }
.map-draw-hint { flex: 1 1 100%; }
.map-draw-actions { display: flex; gap: 6px; margin-left: auto; }
.map-draw-actions .map-btn[disabled] { opacity: 0.4; cursor: default; }
/* A crosshair says "this click puts a point down", which a hand reads faster
   than any sentence. */
.map-canvas.is-drawing { cursor: crosshair; }

/* A drawing's own name, sat on the shape. Transparent so it labels the line
   rather than covering the map, and it must never eat the click. */
.map-mark-label.leaflet-tooltip {
  background: transparent; border: 0; box-shadow: none;
  color: var(--text); font-size: 12px; font-weight: 600;
  text-shadow: 0 0 4px var(--bg), 0 0 4px var(--bg), 0 0 4px var(--bg);
  pointer-events: none;
}
.map-mark-label.leaflet-tooltip::before { display: none; }

/* The map's own box. A viewport-derived height so the map fills what is left
   of the screen instead of a guessed pixel count - a map you have to scroll
   is not a map. */
.map-frame {
  position: relative;
  border: 1px solid var(--border);
  border-radius: var(--radius-card);
  overflow: hidden;
  background: var(--surface);
}
.map-canvas { width: 100%; height: min(72vh, 780px); }
.map-canvas.is-placing { cursor: crosshair; }

/* Leaflet's own chrome, dressed to look like the rest of the app rather than
   like a default Leaflet map. */
.map-frame .leaflet-container { background: #0e1014; font-family: inherit; }
.map-frame .leaflet-bar a {
  background: var(--surface-raised); color: var(--text);
  border-bottom-color: var(--border);
}
.map-frame .leaflet-bar a:hover { background: #23272e; color: var(--text); }
.map-frame .leaflet-control-attribution {
  background: rgba(11, 12, 14, 0.78); color: var(--text-dim);
  font-size: 10px;
}
.map-frame .leaflet-control-attribution a { color: var(--text-secondary); }

/* --- The pins ------------------------------------------------------------- */

/* A pin is a dot at the place with its name beside it. The dot is the anchor;
   the label must never be, or the pin would point near the thing rather than
   at it. */
.map-pin { display: flex; align-items: center; gap: 6px; white-space: nowrap; }
.map-pin-dot {
  width: 14px; height: 14px; border-radius: 50%; flex: 0 0 auto;
  background: var(--pin-colour, var(--accent));
  border: 2px solid #0b0c0e;
  box-shadow: 0 0 0 1px var(--pin-colour, var(--accent));
}
/* An output on anywhere in the device makes its pin bloom - the dashboard
   key's own idea, so a lit thing reads the same on both surfaces. */
.map-pin.is-on .map-pin-dot {
  background: var(--pin-colour, var(--readout));
  box-shadow: 0 0 0 1px var(--pin-colour, var(--readout)),
              0 0 12px 3px rgba(255, 182, 65, 0.55);
}
.map-pin.is-disabled { opacity: 0.45; }
.map-pin-label {
  display: inline-flex; align-items: center; gap: 5px;
  font-size: 12px; color: var(--text); padding: 1px 6px;
  background: rgba(11, 12, 14, 0.72); border-radius: 6px;
  border: 1px solid var(--border);
}
/* WHAT the thing is, beside what it is called (GK, 10 Aug 2026). The short
   token the Controller's own device list shows in brackets - dim, so the name
   still leads and the badge is read only when wanted. */
.map-pin-type {
  font-size: 10px; font-weight: 600; letter-spacing: 0.03em;
  color: var(--text-secondary); background: var(--surface-raised);
  border: 1px solid var(--border); border-radius: 4px;
  padding: 0 4px; line-height: 15px;
}
.map-pin.is-on .map-pin-type { color: var(--readout); border-color: var(--readout); }

/* --- The count bubbles ---------------------------------------------------- */

/* What the map shows instead of devices when it is too far out for a device
   in a place to mean anything (GK). Three sizes so a big number still reads
   without the bubble swallowing the map. */
.map-bubble {
  display: flex; align-items: center; justify-content: center;
  border-radius: 50%; font-weight: 600; color: var(--text);
  background: rgba(46, 107, 255, 0.82);
  border: 2px solid rgba(255, 255, 255, 0.5);
  box-shadow: 0 0 0 6px rgba(46, 107, 255, 0.22);
  cursor: pointer;
}
.map-bubble-s { width: 34px; height: 34px; font-size: 13px; margin: -17px 0 0 -17px; }
.map-bubble-m { width: 42px; height: 42px; font-size: 14px; margin: -21px 0 0 -21px; }
.map-bubble-l { width: 52px; height: 52px; font-size: 15px; margin: -26px 0 0 -26px; }



/* The output rows are the app's own .output-row - only the spacing inside a
   popup is this page's business. */
/* The Remove button. It lived in the pin's popup; since pins open the device
   page it lives in the placing drawer, but it is the same button. */
.map-pop-remove {
  background: transparent; border: 1px solid var(--border);
  border-radius: 10px; color: var(--text-secondary);
  padding: 3px 10px; font-size: 12px; font-family: inherit; cursor: pointer;
}
.map-pop-remove:hover { color: var(--error); border-color: var(--error); }

/* --- The floating controls ------------------------------------------------ */

.map-overlay-tools {
  position: absolute; right: 12px; bottom: 26px; z-index: 500;
  display: flex; flex-direction: column; gap: 8px;
}
.map-btn {
  background: rgba(22, 24, 28, 0.92); color: var(--text-secondary);
  border: 1px solid var(--border); border-radius: 10px;
  padding: 6px 13px; font-size: 12px; font-family: inherit; cursor: pointer;
  transition: color .15s, border-color .15s;
}
.map-btn:hover { color: var(--text); border-color: #3a414c; }

/* --- The placing drawer --------------------------------------------------- */

.map-drawer {
  position: absolute; left: 12px; top: 12px; z-index: 500;
  width: 260px; max-height: calc(100% - 24px); overflow-y: auto;
  background: rgba(22, 24, 28, 0.95); border: 1px solid var(--border);
  border-radius: 14px; padding: 14px 15px;
}
.map-drawer-title { font-size: 15px; color: var(--text); }
.map-drawer-hint { margin-top: 6px; }
.map-drawer-list { margin-top: 10px; display: flex; flex-direction: column; gap: 6px; }
.map-place-row {
  display: flex; align-items: center; gap: 9px; width: 100%;
  background: transparent; border: 1px solid var(--border);
  border-radius: 10px; padding: 7px 9px; cursor: pointer;
  font-family: inherit; text-align: left;
}
.map-place-row:hover { border-color: #3a414c; }
.map-place-row.is-armed { border-color: var(--accent); background: rgba(46, 107, 255, 0.14); }
.map-place-num { color: var(--accent); font-size: 12px; min-width: 16px; }
.map-place-name { color: var(--text); font-size: 14px; flex: 1 1 auto; }
.map-place-type { color: var(--text-dim); font-size: 11px; }

/* One line, over the map, only when there is something true to say. */
.map-say {
  position: absolute; left: 50%; transform: translateX(-50%);
  bottom: 14px; z-index: 500; max-width: min(560px, calc(100% - 40px));
  background: rgba(22, 24, 28, 0.95); border: 1px solid var(--border);
  border-radius: 10px; padding: 8px 14px; font-size: 13px;
  color: var(--text-body);
}
.map-say.is-bad { border-color: var(--error); color: var(--error); }

@media (max-width: 640px) {
  .map-canvas { height: min(66vh, 620px); }
  /* The drawer takes the width on a phone rather than covering the map with
     a floating card the owner then has to place a pin behind. */
  .map-drawer { left: 8px; right: 8px; top: 8px; width: auto; }
  .map-overlay-tools { right: 8px; bottom: 30px; }
}

/* --- Offline maps (GK, 10 Aug 2026) --------------------------------------
   "It might be like what google maps do... allows you to download a
   particular section of the maps. Select the high quality area." */

/* The selection frame. It does not move - the OWNER moves the map behind it.
   pointer-events off throughout, or the frame would eat the pan gesture that
   is the whole way of using it. */
.map-select {
  position: absolute; inset: 8% 10%; z-index: 450;
  pointer-events: none;
  border: 2px dashed var(--accent);
  border-radius: 10px;
  box-shadow: 0 0 0 9999px rgba(11, 12, 14, 0.42);
}
.map-select-note {
  position: absolute; left: 50%; top: -12px; transform: translateX(-50%);
  background: var(--accent); color: #fff;
  font-size: 11px; padding: 2px 9px; border-radius: 10px;
  white-space: nowrap;
}

.map-offline {
  position: absolute; right: 12px; top: 12px; z-index: 500;
  width: 285px; max-height: calc(100% - 24px); overflow-y: auto;
  background: rgba(22, 24, 28, 0.96); border: 1px solid var(--border);
  border-radius: 14px; padding: 14px 15px;
}
.map-offline-h3 {
  font-size: 13px; color: var(--text); margin-top: 16px;
  padding-top: 14px; border-top: 1px solid var(--border);
}
.map-offline-switch {
  display: flex; align-items: center; gap: 9px;
  margin-top: 12px; font-size: 14px; color: var(--text); cursor: pointer;
}
.map-offline-hint { margin-top: 4px; }
.map-detail { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
.map-offline-size { margin-top: 9px; color: var(--text); }
.map-offline-size.is-bad { color: var(--error); }
.map-offline-actions { display: flex; gap: 8px; margin-top: 12px; }
.map-offline-clear { margin-top: 16px; width: 100%; }

.map-progress { margin-top: 12px; }
.map-progress-bar {
  height: 6px; border-radius: 999px; overflow: hidden;
  background: var(--surface-raised); border: 1px solid var(--border);
}
.map-progress-bar span {
  display: block; height: 100%; width: 0;
  background: var(--accent); transition: width .3s;
}

@media (max-width: 640px) {
  /* One drawer's width on a phone, and the two can never both be open. */
  .map-offline { left: 8px; right: 8px; top: 8px; width: auto; }
  .map-select { inset: 12% 6%; }
}

/* The SHARED timed-on panel, opened from a pin's popup. Only its fit inside
   a Leaflet popup is this page's business - the panel itself is the one every
   other surface opens (static/timedon.js). */

/* A row in the drawer's "On the map" list: the same row as the placing list,
   with a Remove on the end rather than a press action. */
.map-place-row.is-placed { cursor: default; }
.map-place-row.is-placed:hover { border-color: var(--border); }
.map-place-row.is-placed .map-place-name { margin-right: 6px; }
.map-place-row .map-pop-remove { flex: none; padding: 2px 9px; }

/* == VC PRESENTATION MODE (GK, 3 Sep 2026) ================================
   "I would like to be able to display the virtual Controller screen on the
   monitor (full size) or TV for presentations."

   THIS IS NOT A SECOND VIRTUAL CONTROLLER. It is the same /vc page, the
   same `vc_screen` socket push, the same port lease and the same presses -
   only the SIZE and the CHROME change. That matters: anything proved on
   /vc is true here by construction, and there is no second copy to keep in
   step. Reached as `/vc?present`; without that query every rule below is
   inert, because nothing carries the class.

   WHY IT SCALES AT ALL. vc.js composes the screen at its true 128x64 and
   then resizes the FINISHED image into whatever the glass measures, so the
   unit has no preferred size - only `.vc-body`'s 500px cap held it small.
   Lift the cap and the identical picture arrives at 1500px, resolved the
   same way at every pixel.

   ARROWS AND KEYS STAY LIVE. The hotspots are positioned in per cent off
   the photograph, so they grow with it - a presentation screen can still
   be driven, which is the point of demonstrating a Controller rather than
   showing a slide of one. */

/* The chrome goes: the status dots and the nav are for working, not for a
   room looking at a Controller. The unit then gets the whole viewport. */
body.vc-present { overflow: hidden; }
body.vc-present .topbar { display: none; }
body.vc-present .content {
  max-width: none;
  padding: 0;
  height: 100vh;
  height: 100dvh;                    /* phones/TV browsers with a chrome bar */
  display: flex;
  align-items: center;
  justify-content: center;
}
body.vc-present .vc-page { width: 100%; }
body.vc-present .vc-stage { margin: 0; width: 100%; }

/* As big as the viewport allows, whichever axis runs out first. The 1.9386
   is the PHOTOGRAPH'S OWN aspect (controller.png is 1200x619) - if that
   file is ever replaced, this number moves with it or the unit stops
   filling the height it claims. A 16:9 screen is 1.78, so in practice the
   width runs out first and the 97vw is the one doing the work. */
body.vc-present .vc-body {
  max-width: none;
  width: min(97vw, calc(96vh * 1.9386));
  width: min(97vw, calc(96dvh * 1.9386));
}

/* !! THE INSET HAS TO BECOME PROPORTIONAL, and this is the one line that
   would be wrong to "tidy". `--vc-inset` is 6px, measured by eye on a
   500px unit; left as 6px a 1500px unit wears a hairline where GK put a
   margin, and the screen reads as sitting loose in the glass.

   A PERCENTAGE padding resolves against the CONTAINING BLOCK's width on
   all four sides - and the containing block here is `.vc-body`, the whole
   unit, NOT the glass the padding sits in. (Measured on the bench, 3 Sep
   2026: 3.158% - the right answer against the 190px glass - came out as
   49px on a 1552px unit, eight times GK's margin. The rule reads as if it
   were the element's own width and it is not.)

   So the number is 6px over the 500px UNIT: 1.2%. That reproduces GK's
   measurement exactly at 500px and scales it honestly from there - and
   because it is one number against one width, it stays equal on all four
   sides the way the original 6px did. */
body.vc-present .vc-screen-area { padding: 1.2%; }

/* The message line is the honest refusal (Serial Control off, no USB) and
   must still be readable across a room - but it must not steal height from
   the unit, so it is lifted out of the flow. */
body.vc-present .vc-controls {
  position: fixed;
  left: 0; right: 0; bottom: 14px;
  margin: 0;
  font-size: 16px;
}

/* The one hint, and it fades. A presentation screen with instructions
   printed across it is a screen showing instructions. */
.vc-hint {
  position: fixed;
  left: 0; right: 0; top: 14px;
  text-align: center;
  font-size: 13px;
  color: var(--text-dim);
  pointer-events: none;
  transition: opacity 0.6s ease;
}
.vc-hint[data-quiet="1"] { opacity: 0; }

/* A mouse pointer parked on a projected screen is the sort of thing a room
   notices. It comes straight back on the first movement. */
body.vc-present.vc-idle { cursor: none; }

/* -- MIRROR MODE IS GONE (GK, 4 Sep 2026) -------------------------------
   "In Present Mode, we need to be able to still press the buttons on the
   virtual controller."

   These rules made the unit read-only on the presentation screen - the
   keys, the knob and the glass had `pointer-events: none` and the up/down
   pair was hidden outright. That was my reading of "I want to drive the
   controller by hand, and the screen just mirrors the controller", and it
   was wrong: he was describing what HE was doing, not asking the page to
   refuse to be used. It locked him out twice, once through the keyboard
   and once through the buttons.

   Presentation mode now only means "the chrome goes and the unit fills the
   screen". The unit itself behaves identically wherever it is drawn, which
   is one behaviour to reason about instead of two. The up/down pair is
   visible here too - it is capped at its 600px size now, so the reason it
   was hidden (two grey slabs on a wall) has gone as well. */

/* -- EDGE TO EDGE (GK, 3 Sep 2026) --------------------------------------
   "Now we need to have the OPTION to make the controller 100% of the
   screen width."

   An OPTION, so the 97vw fit above stays the default and this is the other
   state, not a replacement. `/vc?present&fill` starts in it and W toggles
   it live, because the right answer depends on the actual screen in the
   actual room and is found by looking, not by choosing in advance.

   WHAT IT COSTS, on a wide screen. The unit is a 1.9386 photograph, so at
   100vw it stands 51.6vw tall. That fits any screen squarer than 1.9386,
   which is every 16:9 and 16:10 panel. Past that - a 21:9 ultrawide, say -
   it is taller than the viewport and the case is clipped by about 8% top
   and bottom. That is deliberately allowed rather than blocked: the glass
   sits between 21% and 70% of the photo's height, so the SCREEN, which is
   the thing anyone is looking at, is never touched. Only case edge goes. */
/* The edge-to-edge pill is gone (GK, 3 Sep 2026) - a width in pixels,
   stepped 100 at a time, replaced it. Two states could not find the size a
   particular panel in a particular room wants; a number can. The number is
   applied inline by vc.js, so there is no rule for it here - only the
   stepper's own styling below. */

/* == SHOWING WHAT WAS PRESSED (GK, 3 Sep 2026) ===========================
   "when we press a button combination, can we send that just prior to the
   screen being displayed, and some sort of animation shown on the buttons
   or controller to communicate with someone being taught what was just
   done."

   A demonstration has a gap in it that the demonstrator cannot see, because
   they know what their own hand did. The room does not: a screen changes
   and nobody can say why. This closes that gap - the gesture is ACTED OUT
   on the virtual unit first, and the screen it caused arrives after.

   The ring is drawn OUTSIDE the key ( inset -8% and a spread shadow ) so it
   never covers the photographed button. What is being taught is where the
   finger goes, and hiding the button under the marker would teach the
   opposite. */
.vc-key, .vc-knob { position: absolute; }

.vc-demo-ring {
  position: absolute;
  border-radius: 50%;
  border: 3px solid var(--accent);
  background: rgba(46, 107, 255, 0.18);
  box-shadow: 0 0 0 6px rgba(46, 107, 255, 0.20), 0 0 26px 6px rgba(46, 107, 255, 0.5);
  pointer-events: none;
  opacity: 0;
  z-index: 3;
}

/* A SHORT PRESS IS A TAP: on hard, off hard. Read as a count - three of
   these is unmistakably three presses, which a fade would blur. */
.vc-demo-ring.is-tap { animation: vcDemoTap 200ms steps(1, end); }
@keyframes vcDemoTap {
  0%   { opacity: 0; }
  1%   { opacity: 1; }
  60%  { opacity: 1; }
  61%  { opacity: 0; }
}

/* A LONG PRESS IS A HOLD, and it has to LOOK long - it swells and stays,
   so nobody mistakes it for another tap. */
.vc-demo-ring.is-hold { animation: vcDemoHold 900ms ease-out; }
@keyframes vcDemoHold {
  0%   { opacity: 0; transform: scale(0.86); }
  12%  { opacity: 1; transform: scale(1); }
  75%  { opacity: 1; transform: scale(1); }
  100% { opacity: 0; transform: scale(1.12); }
}

/* The caption says it in words as well. A ring alone teaches WHERE; the
   words teach WHAT, and a learner needs the name of the gesture to ask
   about it later. */
.vc-demo-say {
  position: absolute;
  left: 0; right: 0; bottom: -2.2em;
  text-align: center;
  /* Sized to be read from the back of a room, not from a desk - this is
     the half of the teaching aid that carries the words. */
  font-size: clamp(14px, 2.2vw, 44px);
  font-weight: 600;
  letter-spacing: 0.04em;
  color: var(--accent);
  pointer-events: none;
  opacity: 0;
  transition: opacity 180ms ease;
  line-height: 1.2;
}
.vc-demo-say.is-on { opacity: 1; }

/* ⚠ IN PRESENTATION MODE THE CAPTION IS PINNED TO THE VIEWPORT, not hung
   under the unit. Edge to edge leaves only a few per cent of screen below
   the case, and `body` clips its overflow - so a caption sitting 2.2em
   under the unit fell off the bottom of the wall and the words vanished
   exactly where they were needed most ( seen at 1920x1080, fill on ). */
body.vc-present .vc-demo-say {
  position: fixed;
  bottom: 3vh;
}

/* -- THE DISPLAY TOGGLES (GK, 3 Sep 2026: "just show toggles on the app") --
   Quiet until wanted, and each one shows its own state - a toggle that does
   not say whether it is on is a button you have to press to find out. */
.vc-toggles {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 8px;
  width: 100%;
}
.vc-toggle {
  padding: 5px 12px;
  font-size: 13px;
  line-height: 1.4;
  color: var(--text-secondary);
  background: transparent;
  border: 1px solid var(--border-strong);
  border-radius: 999px;
  cursor: pointer;
  transition: background 120ms linear, color 120ms linear,
              border-color 120ms linear;
}
.vc-toggle:hover { color: var(--text); border-color: var(--accent); }
.vc-toggle.is-on {
  color: var(--text);
  border-color: var(--accent);
  background: rgba(46, 107, 255, 0.22);
}
.vc-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* The stepper reads as ONE control, not three loose pills - minus, the
   number, plus - so the number is plainly the thing the buttons move. */
.vc-width { display: inline-flex; align-items: stretch; gap: 4px; }
.vc-width-step {
  min-width: 30px;
  padding: 5px 8px;
  font-size: 14px;
  line-height: 1.4;
}
/* Tabular figures so the pill does not twitch in width as the number goes
   from 900 to 1000 while someone is stepping through it. */
/* A TEXT BOX, not a label (GK, 3 Sep 2026: "can we type the size in the
   box as well"). It keeps the pill shape of its neighbours so the stepper
   still reads as one control, but it has to look typeable - hence the
   text cursor and the accent border once it has focus. */
.vc-width-value {
  min-width: 86px;
  font-variant-numeric: tabular-nums;
  text-align: center;
  font-family: inherit;
  cursor: text;
  -moz-appearance: textfield;
}
/* The picture sliders' styling came out with the sliders themselves (GK,
   4 Sep 2026: "just set the values and get rid of the sliders"). Smooth and
   Gap are constants in vc.js now, and the Detail readout that used to sit
   beside them went when oversampling did. */

.vc-width-value:focus {
  outline: none;
  color: var(--text);
  border-color: var(--accent);
  background: rgba(46, 107, 255, 0.14);
}

/* On a wall they are scenery, so they go with the pointer and the hint -
   present but not part of the picture until someone reaches for them. */
body.vc-present.vc-idle .vc-toggles { opacity: 0; }
body.vc-present .vc-toggles { transition: opacity 0.6s ease; }

/* Off by default: a working page must not flash at the owner every press.
   It is the presentation and teaching screens that want it. */
body:not(.vc-teach) .vc-demo-ring,
body:not(.vc-teach) .vc-demo-say { display: none; }
