/* WP-D.2: mobile-first on purpose — a phone on the venue LAN is the whole
   point (docs/blx-implementation-plan.md's own exit criterion for this
   WP). Dark by default: a bright white page is the last thing anyone
   wants to be holding at FOH during a show. Large touch targets
   throughout — this is meant to be used one-handed, in the dark, without
   looking. Colour choices echo the app's own Theme.swift (an accent
   purple, a danger red) rather than inventing a separate palette. */

/* The `hidden` attribute is styled by the *user-agent* stylesheet
   (`[hidden] { display: none }`), and any author rule that sets `display`
   on the same element beats it outright — author styles win over UA
   styles regardless of specificity. `.pairingOverlay` below sets
   `display: flex`, so `els.pairingOverlay.hidden = false/true` in app.js
   had no effect whatsoever: the overlay and its 92%-opaque scrim were
   painted over the page permanently, from first load, at z-index 100.

   The symptom was a pairing prompt that could not be dismissed even
   though pairing had plainly succeeded — the live state visibly updating
   *behind* the scrim, because the app underneath was working fine the
   whole time. `!important` rather than a `.pairingOverlay[hidden]`
   override on purpose: it restores the behaviour every `hidden` in this
   page assumes, including any element added later. */
[hidden] {
  display: none !important;
}

/* ===========================================================================
   FLURO LASER — DAY (2026-10-01, experiment)

   The Mac app's `AppTheme.fluroLaserDay` palette, ported to this page token
   for token: every value below is copied from `FluroTheme.swift`'s own
   `fluroLaserDay` literal (itself generated by `spike/fluro_tokens.py` from
   the prototype's `11c` sheet / `10c` desk — never hand-tune one here either,
   fix it there and re-copy). The comment after each line names the Swift
   token it came from, so the two can be diffed by eye.

   Laser's own two rules, which decide every mapping that isn't a literal
   copy: **violet = UI/action (fills), cyan = live/data**. So the accent, the
   fills and the destructive stops are all violet stops of one ramp, while
   anything reporting what the rig is actually doing — the connected dot, the
   LIVE badge on the cam, the agent's own voice in Talk to Lights — is cyan.
   That is why `--agent-text` is no longer pink: there is no pink in Laser,
   and the agent reporting back from the lights is data, not chrome.

   **Geometry is deliberately untouched.** Not one px, gap, radius, font-size
   or breakpoint changed in this pass — the page's existing fit (everything on
   a 390px phone with no horizontal scroll, the deck grid, the flex-fill chain
   under Talk) is the thing this must not spend. Colour, the chassis type ramp
   on legends only (`.panelLabel`-scale text, where there is room for
   Michroma's width), and the static room-glow wash on `body` are the whole
   change. */
:root {
  --bg: #eef0f4;              /* background.deepest — page ground AND inset wells */
  --panel: #fcfcfd;           /* background.panel */
  --filled-slot: #e4e7ee;     /* background.filledSlot — the cam's own bar/chips */
  --border: #dbdee6;          /* border.subtle */
  --border-strong: #b9bfcd;   /* border.strong */
  --text: #1c1430;            /* text.primary */
  --text-dim: #5b566b;        /* text.secondary — NOT .tertiary: this page's dim
                                 text runs to 10.5px, and tertiary (#858292)
                                 drops under 4.5:1 at that size on --panel */
  --accent: #5f22d9;          /* accent.base — violet, every UI fill */
  --accent-bg: rgba(95, 34, 217, 0.12);  /* accent.base at the tint the control
                                 banner and every -active state already used,
                                 kept translucent because `.controlBanner
                                 .collapsed` paints it as a gradient OVER --bg
                                 to go opaque without a second hand-mixed hex */
  /* The ink on a violet fill. `AppTheme.ButtonChrome.accentFill` spells this
     out: a solid `Accent.base` fill with `Background.deepest` as its text —
     which flips correctly between Night and Day for free, because deepest
     does. Day: a near-white ink on saturated violet. Replaces the three
     hand-picked near-black inks this page carried for its dark theme
     (#17123a on accent, #2a0808 on danger, #3a0a20 on the pink RESET). */
  --on-accent: #eef0f4;       /* background.deepest */
  /* The deepest violet stop (accent.lighter), used for exactly the three
     controls that stop the rig: BLACKOUT while it is on, PANIC, and RESET.
     Laser maps `status.danger` onto `accent.base` itself — correct for the
     app, but this page puts REQUEST CONTROL / Send / a fired strip in that
     same violet, and "the kill is the same colour as the ordinary action" is
     not a thing a phone held in the dark should say. One stop deeper down
     Laser's own ramp keeps it inside the system while still reading, at a
     glance, as a different class of button. */
  --kill: #2b0f62;            /* accent.lighter */
  --danger: #5f22d9;          /* status.danger (== accent.base in Laser) */
  --live: #00798c;            /* accentCyan / status.ok — "cyan = live/data" */
  --go: #00798c;              /* status.ok */
  --warn: #304eb2;            /* status.warn */
  --warn-bg: rgba(48, 78, 178, 0.1);
  --warn-border: rgba(48, 78, 178, 0.32);
  --scrim: rgba(221, 224, 232, 0.92);  /* background.scrim, at the overlay
                                 opacity this page already used */
  --shadow: 0 4px 16px rgba(28, 20, 48, 0.16);  /* text.primary, not black: a
                                 light chassis casts a tinted shadow, and pure
                                 black at 0.4 reads as a hole in it */
  /* Ink for the two places text sits on VIDEO rather than on the chassis (the
     cam's own "Connecting…" message, the maintenance screen) — those keep
     their black scrims, since what is behind them is a dark video frame no
     matter which theme the page is wearing, so --text/--text-dim would be
     dark-on-dark there. */
  --on-video: #f4f4f1;        /* glass.text */
  --on-video-dim: #c8cad2;
  /* Talk to Lights' own agent voice. Was a pink picked to sit apart from the
     accent; Laser has no pink, and it does have a rule about exactly this:
     the agent is reporting what the rig is doing, which is data — cyan. The
     label in front of each line stays the quieter half of it, so it is the
     same hue a stop darker, as before. */
  /* One stop down from the palette's own `accentCyan` (#00798c), for the same
     reason `--text-dim` takes `.secondary` rather than `.tertiary`: the agent
     speaks in 13px body text on `--bg`, where the anchor itself measures
     4.47:1 — AA to two decimal places and under it to three. #007283 is the
     same hue at 4.93:1 and is indistinguishable at a glance. The anchor stays
     exactly as the palette gives it everywhere it is a FILL (`--live`, `--go`),
     where the floor is 3:1 and it clears easily. */
  --agent-text: #007283;      /* accentCyan, one step deeper — see above */
  --agent-label: #00606f;
}

/* Laser's chassis type ramp. The same file the Mac app bundles
   (`blx-mac/BLX/Fonts/Michroma-Regular.ttf`, SIL OFL 1.1 — the licence is
   copied in beside it), served from this page's own origin: the CSP in
   firebase.json has no `font-src`, so it falls back to `default-src 'self'`,
   which a self-hosted font satisfies and a Google Fonts URL would not.
   `font-display: swap` means the page never waits on it — the fallback stack
   renders immediately and the legends re-letter when it lands.

   Michroma has no weight axis (`Theme.FontFamily.michroma`'s own note: "it has
   no weight axis of its own, so nothing here ever asks it for one"), so every
   rule below that uses it also drops to `font-weight: 400` rather than letting
   the browser synthesize a fake bold — which would both look wrong and measure
   wider than the real face. */
@font-face {
  font-family: "Michroma";
  src: url("fonts/Michroma-Regular.ttf") format("truetype");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

/* **Legends only.** The app's spec is "Michroma · 7-11px · tracking
   0.10-0.20em · always caps" for chassis labels — section headers, tab names,
   button captions. This page takes the first two and stops short of button
   captions on purpose: Michroma is a very wide face, and BLACKOUT/PANIC/Send/
   REQUEST CONTROL are sized to fit their boxes in a system font at a fixed
   font-size this pass is not allowed to shrink. A legend has room; a 2-column
   safety button at 10.5px does not.

   Applied rule by rule below (search `--legend`) rather than as one grouped
   selector up here, because every one of those rules is declared further down
   this file and would win the cascade on `font-weight`/`letter-spacing` — a
   grouped block here would have quietly left the legends in a synthesized
   fake-bold Michroma at the old tracking.

   Tracking goes to the app's own `.14em` (`Theme.sectionLabelTracking`, the
   midpoint of the spec's range) from this page's existing `.1em` — the one
   width this pass does spend. Verified against the real layout rather than
   assumed, at 320/360/390/560/1024, by spike/verify_web_remote_theme.mjs. */
:root {
  --legend: "Michroma", "Helvetica Neue", Arial, sans-serif;
  /* The Day half of the pair, stated to the UA as well as painted: without
     this, a phone in system dark mode still renders the parts of the page the
     stylesheet does not own — the text caret, scrollbars, the keyboard's own
     accessory tint — for a dark page, on top of a light one. */
  color-scheme: light;
}

* { box-sizing: border-box; }

html, body {
  margin: 0;
  padding: 0;
  background: var(--bg);
  color: var(--text);
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  height: 100%;
  /* No pinch-zoom, no accidental double-tap zoom on GO — see also the
     viewport meta tag in index.html. */
  touch-action: manipulation;
  user-select: none;
  -webkit-user-select: none;
}

body {
  display: flex;
  flex-direction: column;
  min-height: 100%;
  /* The room glow (`AppTheme.roomGlow` — only the Fluro families are drawn on
     it), as much of it as belongs on a phone remote: two fixed radial washes,
     violet from the top-left and cyan from the bottom-right, over the ground.
     The app's version is the live blurred composite of every plotted fixture's
     current colour at 30fps (`RoomGlowBackground.swift`); this is deliberately
     the static reduction — this page has no fixture plot, and a phone held at
     FOH should not be running an animated background behind controls someone
     is trying to hit. Day, per `11d`, is the multiply direction: both washes
     are darker than the ground, so they deepen it rather than lighting it.

     Painted as `background-image` on `body` rather than a fixed pseudo-element
     at a negative z-index: nothing on this page creates a stacking context on
     purpose, and a positioned layer behind non-positioned in-flow content is
     exactly the kind of thing that paints over the deck on one engine and not
     another. `background-attachment: fixed` keeps the wash still while the
     deck scrolls, which is what makes it read as the room and not as part of
     the page. Alpha stays low enough that every contrast pair in :root is
     measured against --bg unchanged. */
  background-image:
    radial-gradient(90vw 70vh at 0% 0%, rgba(95, 34, 217, 0.1), rgba(95, 34, 217, 0) 70%),
    radial-gradient(80vw 60vh at 100% 100%, rgba(0, 121, 140, 0.09), rgba(0, 121, 140, 0) 70%);
  background-attachment: fixed;
  background-repeat: no-repeat;
}

header {
  padding: 14px 16px 10px;
  border-bottom: 1px solid var(--border);
}

.titleRow {
  display: flex;
  align-items: center;
  justify-content: space-between;
}

header h1 {
  font-family: var(--legend);
  font-size: 17px;
  font-weight: 400;
  margin: 0;
  letter-spacing: 0.02em;
  text-transform: uppercase;
}


.status {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 13px;
  color: var(--text-dim);
}

.status .dot {
  width: 9px;
  height: 9px;
  border-radius: 50%;
  background: var(--text-dim);
  flex: none;
}

.status-connected .dot { background: var(--go); }
.status-connecting .dot { background: var(--warn); animation: pulse 1.2s ease-in-out infinite; }
.status-disconnected .dot { background: var(--danger); }

@keyframes pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.35; }
}

main {
  flex: 1;
  /* Completes the `min-height: 0` chain the Talk panel's own flex-fill
     depends on (`.talkInputArea`'s own comment has the full story) —
     `main` is ITSELF a flex item (of `body`), and a flex item's default
     `min-height: auto` means "never shrink below your own content's
     needs," which silently overrides `flex: 1`/`body`'s own definite
     height the moment a DESCENDANT (`.talkLog`, once it has enough
     conversation in it) wants to be taller than the space actually
     available — caught live: `main` measured 740px (correctly clamped
     to the viewport) with an empty log, then 4635px (ordinary
     "auto height hugs content" behaviour, `body`'s own fixed height
     completely defeated) the moment a real conversation's worth of
     content was seeded into `.talkLog`, three DOM levels down. Every
     other element in this chain already has this set; `main` itself was
     the missing link. */
  min-height: 0;
  padding: 12px 16px 16px;
  display: flex;
  flex-direction: column;
  gap: 16px;
  max-width: 560px;
  width: 100%;
  margin: 0 auto;
  /* Leaves room for the fixed SafetyRail footer without content sliding
     underneath it. `--safetyRailHeight` (js/main.js's own
     `applySafetyRailHeight`) is a real, live measurement of the rail's
     own height, not a guessed constant — it has to be, for the Talk
     panel's own flex-fill chain (`.talkInputArea`'s own comment) to land
     flush against the rail rather than leaving the gap a merely
     "comfortable, generous" static guess left, found live: 180px here
     against a real ~125px rail measured 55px too generous, which is
     exactly the size of gap that showed up once the Talk panel actually
     needed this number to be precise rather than merely large enough.
     `180px` is still the fallback for the instant before that first
     measurement lands. The `+8px` is the same small, deliberate slack
     `.talkInputArea`'s own padding-top already visually absorbs
     elsewhere on this page — erring generous by a few px costs nothing;
     the rail's own content is what actually varies (a longer/shorter
     `env(safe-area-inset-bottom)`, primarily), which is exactly what a
     live measurement — rather than a hand-maintained guess — tracks
     automatically. */
  padding-bottom: calc(var(--safetyRailHeight, 180px) + 8px);
}

/* 2026-10-01 follow-up — "the button view gets cut off at the bottom; you
   can't scroll all the way to the end of the second layer." Measured on a
   390×844 phone: at maximum scroll the last deck row ended 110px BELOW the
   rail's top edge, genuinely unreachable.

   `min-height: 0` above is the cause, and it is still right for Talk — it is
   what lets `.talkLog` be clipped and scroll internally instead of growing to
   fit the whole conversation. But on a flex item it also means "you may be
   shorter than your own content," and Buttons has no internal scroller to
   take up the slack: `main`'s box came out 607px tall around 1289px of deck,
   so the generous `padding-bottom` just above — the entire rail reserve —
   was painted at the bottom of the *box*, hundreds of px above where the
   content actually ended. The reserve was real the whole time; it simply
   wasn't where the overflow was.

   Restoring the flex default (`auto` = don't shrink below your own content)
   for the Buttons tab alone lets `main` grow to the deck's real height, which
   puts that padding back underneath the last row where it belongs. The
   attribute comes from js/tabs.js's own `applyActive`; with no attribute at
   all (before the first connection) the Talk-safe `min-height: 0` stands. */
body[data-tab="buttons"] main {
  min-height: auto;
}

.panel {
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 14px;
  padding: 14px;
}

.panelLabel {
  font-family: var(--legend);
  font-size: 11px;
  font-weight: 400;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--text-dim);
  margin-bottom: 10px;
}

.placeholder {
  font-size: 13px;
  color: var(--text-dim);
  font-style: italic;
}

.notice {
  font-size: 12px;
  color: var(--warn);
  background: var(--warn-bg);
  border: 1px solid var(--warn-border);
  border-radius: 8px;
  padding: 8px 10px;
  margin-bottom: 10px;
}

/* DECK — one row per layer: name + CLEAR, an opacity fader, then a
   strip-fire grid. The PLAYBACK mode pills and GROUPS chips this page
   used to have here are gone (index.html's own header comment) — their
   CSS went with them rather than sitting unused. BACK/GO (2026-10-01
   follow-up, js/remote.js's own doc comment) went the same way. */

.deckLayers {
  display: flex;
  flex-direction: column;
  gap: 14px;
}

.layerRow + .layerRow {
  padding-top: 14px;
  border-top: 1px solid var(--border);
}

.layerHeader {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 8px;
}

/* The layer names — "Scenes", "Movement" — in the same chassis face as the
   DECK and Opacity legends either side of them (2026-10-01 follow-up), so a
   deck row reads as one piece of panel rather than data dropped into it.
   Bold on request: Michroma ships a single weight, so this is the browser's
   synthesized bold, which is exactly what the legend rules above avoid at
   11px — at this size, on a name rather than a caption, it holds up. The
   font is wide, so the size comes down a point to keep a long layer name
   clear of its own CLEAR button; checked at 320/360/390 in
   spike/verify_web_remote_mobile.mjs. */
.layerName {
  font-family: var(--legend);
  font-size: 12px;
  font-weight: 700;
  letter-spacing: 0.06em;
}

.layerTransport {
  display: flex;
  gap: 6px;
}

/* Reuses `.master`'s own label/input styling (below) — same look as the
   SafetyRail's fader, just narrower margins to sit between a layer's
   header and its strip grid instead of alone in the fixed footer. */
.layerOpacityRow {
  margin-bottom: 12px;
}

.layer-transport-btn {
  border: 1px solid var(--border-strong);
  background: var(--bg);
  color: var(--text);
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.04em;
  padding: 7px 12px;
  border-radius: 8px;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}

.layer-transport-btn:active { transform: scale(0.96); }

/* No GO/BACK to sit quieter than any more (2026-10-01 follow-up) — plain
   `.layer-transport-btn` styling is the whole look now. */
.layer-transport-btn-clear {
  color: var(--text-dim);
}

.stripRow {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(72px, 1fr));
  gap: 6px;
}

.stripBtn {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 2px;
  border: 1px solid var(--border-strong);
  background: var(--bg);
  color: var(--text-dim);
  border-radius: 8px;
  padding: 8px 4px;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  min-height: 52px;
}

.stripBtn:active { transform: scale(0.96); }

.stripBtn-active {
  border-color: var(--accent);
  background: var(--accent-bg);
  color: var(--text);
}

.stripNumber {
  font-size: 10px;
  font-weight: 700;
  color: var(--text-dim);
}

.stripBtn-active .stripNumber { color: var(--accent); }

.stripLabel {
  font-size: 10px;
  text-align: center;
  line-height: 1.2;
  overflow: hidden;
  text-overflow: ellipsis;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
}

.error-banner {
  background: var(--danger);
  color: var(--on-accent);
  padding: 12px 16px;
  border-radius: 10px;
  font-size: 14px;
  font-weight: 600;
  text-align: center;
}

/* SafetyRail recreation (SafetyRail.swift) — fixed at the bottom, exactly
   like every pop-out window's own copy: kill controls stay reachable
   from wherever the operator is on the page. */

.safetyRail {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  background: var(--panel);
  border-top: 1px solid var(--border);
  padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.master label {
  display: flex;
  justify-content: space-between;
  font-family: var(--legend);
  font-size: 11px;
  font-weight: 400;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--text-dim);
  margin-bottom: 6px;
}

/* The value is a readout, not a legend — it keeps the UI face and its
   semibold, the same split the app draws between Michroma legends and its own
   data face. */
.master label span {
  color: var(--text);
  font-weight: 600;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  letter-spacing: normal;
}

.master input[type="range"] {
  width: 100%;
  height: 32px;
  -webkit-appearance: none;
  appearance: none;
  background: transparent;
}

/* `--fillPct` (set in JS on every render/drag tick, remote.js's own
   setFaderFill) is what actually makes 100% look full — a bare
   `background: var(--border-strong)` here gave no visual difference
   between 0% and 100% at all, just a thumb sitting at whichever end,
   caught directly against a real fader rather than assumed from the CSS
   alone. Firefox gets the equivalent via `::-moz-range-progress` below,
   which needs no gradient math since it's already just the filled
   portion by definition. */
.master input[type="range"]::-webkit-slider-runnable-track {
  height: 6px;
  border-radius: 3px;
  background: linear-gradient(to right, var(--accent) var(--fillPct, 0%), var(--border-strong) var(--fillPct, 0%));
}

.master input[type="range"]::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: 26px;
  height: 26px;
  border-radius: 50%;
  background: var(--accent);
  margin-top: -10px;
  border: 3px solid var(--panel);
}

.master input[type="range"]::-moz-range-track {
  height: 6px;
  border-radius: 3px;
  background: var(--border-strong);
}

.master input[type="range"]::-moz-range-progress {
  height: 6px;
  border-radius: 3px;
  background: var(--accent);
}

.master input[type="range"]::-moz-range-thumb {
  width: 20px;
  height: 20px;
  border-radius: 50%;
  background: var(--accent);
  border: 3px solid var(--panel);
}

.safetyButtons {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 6px;
}

.safety-btn {
  border: 1px solid var(--border-strong);
  background: var(--bg);
  color: var(--text);
  font-size: 10.5px;
  font-weight: 700;
  letter-spacing: 0.03em;
  padding: 10px 2px;
  border-radius: 8px;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}

.safety-btn:active { transform: scale(0.96); }

/* The kill stop, not --accent/--danger — see --kill's own comment: in Laser
   those two are the same violet, and BLACKOUT must not look like an ordinary
   fired action. */
.safety-btn-danger.safety-btn-on {
  background: var(--kill);
  border-color: var(--kill);
  color: var(--on-accent);
}

.safety-btn-on {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--on-accent);
}

.safety-btn-panic {
  color: var(--kill);
  border-color: var(--kill);
}

.footerInfo {
  text-align: center;
  color: var(--text-dim);
  font-size: 11px;
}

/* 2026-09-30 follow-up — footer decluttering: the "BLX <version> · api
   vN" line and "Forget this node" both go, visually. CSS-only, not a
   markup/JS removal — `#footerInfo`'s own content is still set by
   remote.js (that file's own "verified unmodified" DOM contract,
   spike/verify_web_remote.mjs), and `#forgetNodeBtn`'s visibility/click
   wiring is still real, tested behaviour
   (spike/verify_web_remote_hosted.mjs actually drives a click on it) —
   hiding rather than deleting means neither script needs touching, and
   "forget this node" is still reachable by anyone who genuinely needs to
   re-pair a hosted browser with a different node; it's just not sitting
   in the footer as visual noise for everyone else. `!important` since
   `#forgetNodeBtn`'s own `hidden` attribute is toggled on/off by
   js/connect.js at runtime (hosted mode only) — this has to keep winning
   either way. */
#footerInfo, #forgetNodeBtn {
  display: none !important;
}

/* WP-C.3 follow-up: the pairing overlay — full-screen, above everything
   else (including the fixed SafetyRail footer), since nothing behind it
   is usable until this resolves one way or the other. */

.pairingOverlay {
  position: fixed;
  inset: 0;
  background: var(--scrim);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
  z-index: 100;
}

.pairingCard {
  width: 100%;
  max-width: 320px;
  background: var(--panel);
  border: 1px solid var(--border-strong);
  border-radius: 14px;
  padding: 24px 20px;
  text-align: center;
}

.pairingCard h2 {
  margin: 0 0 8px;
  font-size: 18px;
}

.pairingCard p {
  margin: 0 0 18px;
  color: var(--text-dim);
  font-size: 14px;
  line-height: 1.4;
}

.connectivityEmergency {
  margin-top: -6px;
  font-size: 12px;
}

.connectivityEmergency a {
  color: var(--accent);
}

/* "Block internet access" (js/maintenance.js) — the livecam feed
   full-bleed, a small badge in the corner (the /livecam standalone
   page's own layout, reused at this same proportion), and the fixed
   message as an ordinary .pairingOverlay/.pairingCard on top — dimming
   the video behind it is the same look that overlay already gives a
   pairing form over the regular remote UI, not a separate effect. */
.maintenanceScreen {
  position: fixed;
  inset: 0;
  z-index: 150;
  background: #000;
}

.maintenanceVideo {
  width: 100%;
  height: 100%;
  object-fit: contain;
  background: #000;
}

.maintenanceBadge {
  position: absolute;
  top: 16px;
  left: 16px;
  padding: 4px 10px;
  border-radius: 999px;
  font-size: 13px;
  font-weight: 600;
  letter-spacing: 0.04em;
  background: rgba(0, 0, 0, 0.6);
  color: var(--text);
}

/* "cyan = live/data" (:root's own Laser note) — the one place this page used a
   broadcast-red LIVE badge. It sits on video, so its ink stays a flat light
   value rather than --on-accent. */
.maintenanceBadge.live {
  background: var(--live);
  color: var(--on-video);
}

.maintenanceLivecamMsg {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  padding: 16px;
  gap: 12px;
  color: var(--on-video-dim);
}

.maintenanceLivecamMsg p {
  margin: 0;
}

.pairingPinInput {
  width: 100%;
  font-size: 28px;
  letter-spacing: 0.3em;
  text-align: center;
  background: var(--bg);
  border: 1px solid var(--border-strong);
  border-radius: 10px;
  color: var(--text);
  padding: 12px;
  margin-bottom: 14px;
}

.pairingPinInput:focus {
  outline: none;
  border-color: var(--accent);
}

.pairingSubmitBtn {
  width: 100%;
  background: var(--accent);
  border: none;
  border-radius: 10px;
  color: var(--on-accent);
  font-size: 16px;
  font-weight: 600;
  padding: 12px;
}

.pairingSubmitBtn:active {
  opacity: 0.8;
}

.pairingError {
  margin-top: 14px;
  color: var(--danger);
  font-size: 13px;
  font-weight: 600;
}

/* Remote-access follow-on: the connect screen (hosted mode's own address
   entry) reuses .pairingOverlay/.pairingCard wholesale — same card, same
   scrim, same "nothing behind it is usable yet" framing — so only the
   input itself needs its own rule: a URL is long and left-to-right, the
   opposite of a PIN's short, centered, huge-digit treatment. */

.connectUrlInput {
  font-size: 16px;
  letter-spacing: normal;
  text-align: left;
  text-transform: none;
}

/* The pairing overlay's own "which node is this" line — empty and
   collapsed (via :empty) for the node-served page, which has no
   separate address of its own to name. */

.pairingNodeLabel {
  margin: -8px 0 18px;
  font-size: 12px;
  color: var(--text-dim);
  word-break: break-all;
}

.pairingNodeLabel:empty {
  display: none;
}

/* Hosted mode's own way back out of a node — quiet on purpose. Hidden
   outright since the 2026-09-30 footer cleanup (`#forgetNodeBtn` in the
   `display: none !important` rule below); kept rather than deleted in
   case hosted mode ever needs its own way back without this. */

.forgetNodeBtn {
  margin-top: 4px;
  background: none;
  border: none;
  color: var(--text-dim);
  font-size: 11px;
  text-decoration: underline;
  padding: 4px;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}

/* Livecam widget (js/livecam-widget.js) — a small floating preview of
   /livecam's own stream, expandable to half the window. `.hidden` here is
   the same "force hide, beat any unconditional `display`" convention
   `[hidden]` above already uses on this page — js/livecam-viewer.js (also
   used by the standalone /livecam page, whose own stylesheet defines this
   class too) toggles it directly rather than the `hidden` attribute,
   since `.livecamMsg` below sets `display: flex` unconditionally, which
   would otherwise beat the attribute's UA-default styling exactly as
   `[hidden]`'s own comment above describes. */

.hidden { display: none !important; }

/* z-index 90: above the SafetyRail and the rest of the page, but below
   .pairingOverlay's 100 — a first-time connect/pairing screen still owns
   the whole page while it's up, exactly as that section's own comment
   promises, with no extra JS coupling needed to make that true. */

.livecamWidget {
  position: fixed;
  width: 168px;
  z-index: 90;
  background: var(--panel);
  border: 1px solid var(--border-strong);
  border-radius: 12px;
  overflow: hidden;
  box-shadow: var(--shadow);
}

/* Mobile follow-up — drag-to-a-corner (js/livecam-widget.js's own doc
   comment: "the same interaction every standard PIP video player
   offers"). `data-corner` picks which pair of edges this anchors to;
   `--livecamTopGap`/`--livecamBottomGap` (measured by that same file)
   clear the control banner / SafetyRail footer respectively, so a
   bottom-anchored widget never sits on top of PANIC/BLACKOUT any more
   than a top-anchored one sits under the control banner. Not applied
   while `.expanded` — that state sets its own `top`/`right`/`left`/
   `bottom` directly, further down, and while `.dragging` — an active
   drag positions via inline `left`/`top`, which these (lower-specificity,
   class-based) rules would otherwise fight for the same properties. */
.livecamWidget[data-corner="top-right"]:not(.expanded):not(.dragging) {
  top: var(--livecamTopGap, 12px);
  right: 12px;
}
.livecamWidget[data-corner="top-left"]:not(.expanded):not(.dragging) {
  top: var(--livecamTopGap, 12px);
  left: 12px;
}
.livecamWidget[data-corner="bottom-right"]:not(.expanded):not(.dragging) {
  bottom: var(--livecamBottomGap, 12px);
  right: 12px;
}
.livecamWidget[data-corner="bottom-left"]:not(.expanded):not(.dragging) {
  bottom: var(--livecamBottomGap, 12px);
  left: 12px;
}

.livecamWidget.dragging {
  transition: none;
  opacity: 0.9;
}

.livecamWidgetBar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 4px 6px;
  /* Was a 35% black wash, which read as "a shade darker than the dark panel
     behind it" — on a light chassis the same wash is a near-black bar bolted
     to a white card. The filled-slot stop is what that relationship is called
     in the palette, and it tracks the theme instead of assuming one. */
  background: var(--filled-slot);
  cursor: grab;
  /* Stops the browser's own scroll/pan gesture from competing with the
     drag handler (js/livecam-widget.js's own doc comment on why pointer
     events alone aren't enough on a touch device). */
  touch-action: none;
}

.livecamWidget.dragging .livecamWidgetBar { cursor: grabbing; }

.livecamBadge {
  font-family: var(--legend);
  font-size: 10px;
  font-weight: 400;
  /* The low end of the 0.10-0.20em range, not the .14em the bigger legends
     take: this one is a 10px chip inside a 168px widget. */
  letter-spacing: 0.1em;
  padding: 2px 6px;
  border-radius: 999px;
  background: var(--border);
  color: var(--text-dim);
}

.livecamBadge.live {
  background: var(--live);
  color: var(--on-video);
}

.livecamWidgetBtns {
  display: flex;
  gap: 4px;
}

.livecamWidgetBtn {
  width: 22px;
  height: 22px;
  border: none;
  border-radius: 6px;
  background: var(--border);
  color: var(--text);
  font-size: 13px;
  line-height: 1;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}

.livecamWidgetBtn:active { transform: scale(0.94); }
.livecamWidgetBtn:disabled { opacity: 0.4; cursor: default; }

/* Minimized (mobile follow-up) — the bar/video stay mounted (never
   torn down, so restoring is instant, no reconnect) but hidden; only
   `.livecamMiniBubble` (below) shows, at a small fixed size regardless of
   which corner it's anchored to. `!important`-free: `display: none` here
   simply wins over the video wrap's own unconditional `display` the same
   way `[hidden]`'s own comment at the top of this file explains, since
   nothing else sets `display` on these two more specifically. */
.livecamWidget.minimized {
  width: 44px;
  height: 44px;
  border-radius: 50%;
}
.livecamWidget.minimized .livecamWidgetBar,
.livecamWidget.minimized .livecamVideoWrap {
  display: none;
}
.livecamWidget:not(.minimized) .livecamMiniBubble {
  display: none;
}

.livecamMiniBubble {
  width: 44px;
  height: 44px;
  border: none;
  background: var(--panel);
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: grab;
  touch-action: none;
  -webkit-tap-highlight-color: transparent;
}
.livecamWidget.dragging .livecamMiniBubble { cursor: grabbing; }

.livecamMiniDot {
  width: 14px;
  height: 14px;
  border-radius: 50%;
  background: var(--text-dim);
}
.livecamMiniDot.live {
  background: var(--live);
  animation: pulse 1.4s ease-in-out infinite;
}

/* The docked phone layout's own narrow row carries a label and an expand
   hint alongside that dot — see the phone-layout block at the end of this
   file. Hidden at every other width, where `.livecamMiniBubble` is a 44px
   circle with nothing in it but the dot itself. */
.livecamMiniLabel,
.livecamMiniHint {
  display: none;
}
.livecamWidget.livecamOffline .livecamMiniDot {
  opacity: 0.5;
}

/* js/livecam-widget.js's own "the node is unreachable" gate — a
   deliberately separate state from the video simply not having connected
   yet (still shows the ordinary "Connecting…"/"lights aren't live"
   message in that case): dimmed, and unambiguously labelled, so it never
   reads as "the feed is just quiet right now." */
.livecamWidget.livecamOffline .livecamVideoWrap {
  opacity: 0.5;
}

.livecamVideoWrap {
  position: relative;
  width: 100%;
  aspect-ratio: 4 / 3;
  background: #000;
}

.livecamVideoWrap video {
  width: 100%;
  height: 100%;
  object-fit: contain;
  display: block;
}

.livecamMsg {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  padding: 8px;
  gap: 6px;
  background: rgba(0, 0, 0, 0.4);
}

.livecamMsg p {
  margin: 0;
  font-size: 10.5px;
  /* On video, behind the black scrim just above — not on the chassis. */
  color: var(--on-video-dim);
  line-height: 1.3;
}

.livecamPlayBtn {
  font: inherit;
  font-size: 11px;
  padding: 4px 10px;
  border-radius: 6px;
  border: 0;
  background: var(--accent);
  color: var(--on-accent);
  cursor: pointer;
}

/* Expanded: half the window, split along whichever axis keeps the video
   large without ever covering the SafetyRail's kill controls — the same
   "reachable from wherever the operator is" rule .safetyRail's own
   comment already states for the rest of this page. --livecamRailGap is
   measured and set by js/livecam-widget.js: the rail's real height
   depends on the safe-area inset, not a fixed constant. */

.livecamWidget.expanded {
  width: auto;
  border-radius: 0;
}

.livecamWidget.expanded .livecamVideoWrap {
  aspect-ratio: auto;
  height: calc(100% - 32px);
}

/* Both scoped to `.expanded` too (not just the orientation class alone):
   js/livecam-widget.js only touches the orientation classes while
   expanded, so an un-scoped rule here would keep positioning a collapsed
   widget at its last expanded spot instead of back at its small corner —
   caught via a CDP-driven expand/collapse check, not by inspection. */

.livecamWidget.expanded.livecamExpandedLandscape {
  top: 0;
  right: 0;
  left: auto;
  bottom: var(--livecamRailGap, 0px);
  width: 50vw;
  height: auto;
}

.livecamWidget.expanded.livecamExpandedPortrait {
  top: 0;
  left: 0;
  right: 0;
  bottom: auto;
  width: auto;
  height: 50vh;
}

/* The other half of "expand to half the window": the rest of the page
   actually reflows into the *remaining* half, rather than just sitting
   underneath the widget's own overlay unaware anything changed — the gap
   caught directly against a real expand, not by inspection (a wide-screen
   `padding-right` alone left DECK's own strip grid, at its full width,
   invisible behind the widget the whole time). `body`'s own padding
   shrinks every normal-flow child's (header/.controlBanner/main) content
   box together with one rule, since `.stripRow`'s `repeat(auto-fill,
   minmax(72px,1fr))` grid already reflows to fewer columns on its own
   once it has less width to work with — nothing about DECK itself needs
   to know this feature exists. `.safetyRail` is the one exception:
   `position: fixed` ignores an ancestor's padding entirely, so it needs
   its own matching `right`/nothing-for-portrait override directly. The
   literal 50vw/50vh here are the same constants the widget's own
   `width`/`height` above use — no measurement needed, since both sides
   are already sized off one shared number. */

body.livecamSplitRight {
  padding-right: 50vw;
}

body.livecamSplitRight .safetyRail {
  right: 50vw;
}

body.livecamSplitBottom {
  padding-top: 50vh;
}

/* Control banner (js/control.js, RemoteControl.swift) — "only one
   operator drives the remote at a time." Still the first child of
   <body> in document order (so js/livecam-widget.js's own
   --livecamTopGap measurement of it is unaffected), but `position:
   sticky` now (2026-09-30 follow-up) rather than plain normal flow — it
   stays on screen, request/relinquish button always reachable, instead
   of scrolling away with the rest of the page. `.collapsed` (below,
   toggled by a scroll listener in control.js) is the other half of that
   follow-up: a shorter strip once the page has actually scrolled, so a
   permanently-sticky banner doesn't permanently cost the same vertical
   space DECK/Talk need underneath it.

   Layout: a 3-column grid (`1fr auto 1fr`) puts the button in the
   visual centre of the row — "prominent," not tucked in the far corner
   — while the text stays left-aligned in column 1. Deliberately NOT
   `min-width: 0` on `.controlBannerText`: a grid track's default
   `min-width: auto` sizes to its content's own natural (un-wrapped)
   width, and `.controlBannerStatus`'s own `white-space: nowrap` below
   makes that natural width the FULL single-line text — "Someone else
   has control," the longest status string, still reserves enough
   column-1 space to never wrap onto a second line, even though the
   button visually centres in whatever's left. */

.controlBanner {
  position: sticky;
  top: 0;
  z-index: 95;
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  gap: 12px;
  padding: 10px 16px;
  background: var(--accent-bg);
  border-bottom: 1px solid var(--border-strong);
  transition: padding 0.15s ease;
}

.controlBannerText {
  grid-column: 1;
  display: flex;
  flex-direction: column;
}

.controlBannerStatus {
  font-size: 13px;
  font-weight: 700;
  white-space: nowrap;
}

.controlBannerSub, .controlBannerQueue {
  font-size: 11px;
  color: var(--text-dim);
}

.controlBannerBtn {
  grid-column: 2;
  justify-self: center;
  font-size: 15px;
  font-weight: 700;
  padding: 13px 22px;
  border-radius: 10px;
  border: 0;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  transition: padding 0.15s ease, font-size 0.15s ease;
}

/* The connection dot, in the banner's own third grid column so it sits just
   to the right of the centred control button (2026-10-01 follow-up). Hidden
   by default: at desktop widths `<header>` still carries the full status —
   dot and wording both — and two dots saying the same thing is worse than
   one. The phone layout, where that header is gone entirely, turns it on. */
.controlBannerDot {
  grid-column: 3;
  justify-self: start;
  display: none;
}

.controlBannerBtn:active { transform: scale(0.97); }
.controlBannerBtn:disabled { opacity: 0.6; }

/* Collapsed (js/control.js's own scroll listener, `window.scrollY > 4`)
   — the button is never hidden, only shrunk: "always visible and
   actionable" applies to the collapsed state just as much as the full
   one. The sub-line (countdown/queue note) is the one thing that
   disappears outright — it's the least essential text on the banner,
   and dropping it is most of where the collapsed height saving actually
   comes from. */
.controlBanner.collapsed {
  padding: 4px 16px;
  /* Opaque once collapsed (2026-10-01 follow-up). `--accent-bg` is a 16%
     translucent accent, which is fine at the top of the page where there is
     nothing behind this banner — but collapsed means the page HAS scrolled,
     and a sticky, see-through strip with DECK's own buttons sliding about
     underneath it reads as a rendering fault rather than a header. Painted
     as the same tint over the page background rather than as a hand-computed
     flat colour, so it still tracks both variables if either is retuned. */
  background-color: var(--bg);
  background-image: linear-gradient(var(--accent-bg), var(--accent-bg));
}
.controlBanner.collapsed .controlBannerSub,
.controlBanner.collapsed .controlBannerQueue {
  display: none;
}
.controlBanner.collapsed .controlBannerStatus {
  font-size: 11px;
}
.controlBanner.collapsed .controlBannerBtn {
  padding: 6px 14px;
  font-size: 12px;
}

.controlBannerBtn-request { background: var(--accent); color: var(--on-accent); }
.controlBannerBtn-cancel { background: var(--bg); color: var(--text); border: 1px solid var(--border-strong); }
.controlBannerBtn-release { background: var(--kill); color: var(--on-accent); }

/* The actual enforcement is server-side (RemoteControl.swift's own
   ControlArbiter, re-checked on every POST /v1/commands) — this is the
   client-side courtesy that keeps a non-holder from ever sending a
   doomed request. Every existing control on the page, gated uniformly
   (the "fully locked, no bypass" choice), by its own existing id/class —
   remote.js needs no changes at all for this. bizarroCLI web-remote plan
   §4: "add the new tab's container to that CSS selector list... nothing
   to build here, just don't forget to opt in" — #buttonsPanel/#talkPanel
   cover both tabs' own RESET buttons too, not just DECK, since both live
   inside their tab's own panel container. */

body.controlLocked #buttonsPanel,
body.controlLocked #talkPanel,
body.controlLocked .safetyRail {
  opacity: 0.4;
  pointer-events: none;
}

/* 2026-10-01 follow-up — which mode the remote is in is now shared state
   owned by whoever holds control (js/tabs.js), so a viewer without it can no
   longer pick a tab for themselves any more than they can fire a cue: their
   page follows the holder's. Dimmed on the BUTTONS, deliberately not on
   `.tabBar` itself the way the three regions above are dimmed as wholes —
   that bar is `position: sticky` on a phone, and an opacity below 1 would
   turn its background translucent and let the deck scroll visibly through a
   bar whose entire job is to stay put. */
body.controlLocked .tabBtn {
  opacity: 0.4;
  pointer-events: none;
}

/* bizarroCLI web-remote plan §4 — the tab bar itself. Sits between the
   header and <main>, same full-width/max-width rhythm as everything
   else on the page. */

.tabBar {
  display: flex;
  gap: 6px;
  padding: 10px 16px 0;
  max-width: 560px;
  width: 100%;
  margin: 0 auto;
  box-sizing: border-box;
}

.tabBtn {
  flex: 1;
  background: var(--panel);
  border: 1px solid var(--border);
  color: var(--text-dim);
  font-size: 13px;
  font-weight: 600;
  /* Caps (2026-10-01 follow-up). Done here rather than by rewriting the
     words in index.html so the accessible name a screen reader announces
     stays "Talk to Lights" rather than being spelled out as an initialism. */
  text-transform: uppercase;
  letter-spacing: 0.02em;
  padding: 10px 4px;
  border-radius: 10px 10px 0 0;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}

.tabBtn.tab-active {
  background: var(--accent-bg);
  border-color: var(--accent);
  color: var(--text);
}

/* 2026-10-01 follow-up — a mode switch now genuinely takes a few real
   seconds (js/tabs.js's own `setActive`, properly awaiting every ramp
   step rather than firing them and moving on). Without this, a tap on
   either tab mid-switch was silently swallowed by the `switching` guard
   with nothing to show for it — this at least makes "still switching"
   visible instead of looking unresponsive. */
.tabBtn:disabled {
  opacity: 0.5;
  cursor: default;
}

/* Shared by DECK's own panel header and Talk's — a `.panelLabel` that
   also carries a RESET (and, for Talk, History) button on the same row,
   without every other existing `.panelLabel` needing a wrapper it
   doesn't use. */

.panelLabelRow {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 10px;
}

/* RESET sits just right of the DECK legend (2026-10-01 follow-up) instead of
   being pushed to the far edge by the row's own `space-between`. Scoped to
   the Buttons panel deliberately: Talk's copy of this row carries three
   controls (i / History / RESET) that are meant to stay grouped at the right,
   away from the heading. */
#buttonsPanel .panelLabelRow {
  justify-content: flex-start;
  gap: 12px;
}

.panelLabelRow > span:first-child {
  margin-bottom: 0;
}

.talkHeaderBtns {
  display: flex;
  gap: 6px;
}

/* "i" info button (2026-09-30 follow-up) — a small round icon button,
   distinct from History/RESET's own text-pill shape (`.resetBtn`) so it
   reads as "help," not a third action of the same weight as those two.
   Sits to their left in source order, matching the request. */
.talkInfoBtn {
  width: 22px;
  height: 22px;
  flex: none;
  border: 1px solid var(--border-strong);
  border-radius: 50%;
  background: var(--bg);
  color: var(--text-dim);
  font-size: 12px;
  font-style: italic;
  font-family: Georgia, "Times New Roman", serif;
  line-height: 1;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.talkInfoBtn:active { transform: scale(0.94); }

.talkInfoCard {
  max-width: 420px;
  max-height: 80vh;
  display: flex;
  flex-direction: column;
  text-align: left;
}

.talkInfoBody {
  overflow-y: auto;
  margin-bottom: 14px;
  font-size: 13px;
  line-height: 1.5;
}

.talkInfoBody p {
  margin: 0 0 10px;
  color: var(--text);
}

.talkInfoSubhead {
  font-family: var(--legend);
  font-weight: 400;
  color: var(--text-dim);
  font-size: 11px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
}

.talkInfoBody ul {
  margin: 0;
  padding-left: 18px;
}

.talkInfoBody li {
  margin-bottom: 6px;
  color: var(--text-dim);
}

.resetBtn {
  background: var(--bg);
  border: 1px solid var(--border-strong);
  color: var(--text-dim);
  font-size: 10.5px;
  font-weight: 700;
  letter-spacing: 0.04em;
  padding: 6px 10px;
  border-radius: 8px;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}

.resetBtn:active { transform: scale(0.96); }

/* A destructive, shared action (2026-09-30 follow-up: RESET stops every
   effect AND clears the conversation for every connected viewer) gets a
   louder button than an ordinary one — the same pink Talk to Lights'
   own agent-response text already uses (`--agent-text`), not a new
   colour, so "pink" reads as "this is the Talk to Lights surface talking
   to you," not an unrelated warning hue. Placed after both `.resetBtn`
   and `.pairingSubmitBtn` in this file so it wins the cascade on either
   button (the RESET trigger itself, and the confirm popup's own "Reset
   for everyone") without needing extra specificity. */
/* Still the loudest button on the page, now in the kill stop rather than the
   agent's own colour: the reason the old pink was reused here was that pink
   WAS Talk to Lights' voice, and under Laser that voice is cyan — the hue this
   page gives the connected dot and the LIVE badge. A destructive, everyone-
   sees-it action cannot be the same colour as "the rig is live and fine", so
   RESET joins BLACKOUT/PANIC on --kill instead. */
.resetBtn-danger {
  background: var(--kill);
  border-color: var(--kill);
  color: var(--on-accent);
}

/* Talk to Lights — a terminal-styled scrollback + a single-line input
   (js/talk.js). Monospace, per the plan doc's own description. */

.talkPanelSection {
  display: flex;
  flex-direction: column;
  /* Part of the flex-fill chain `#talkPanel`'s own comment (below)
     describes — `#talkPanel` (this element's parent) is itself `flex: 1`
     inside `main`, and this needs the same to actually reach `#talkPanel`'s
     own full (already-viewport-bounded) height rather than sitting at
     its own intrinsic content size inside a taller parent. */
  flex: 1;
  min-height: 0;
}

/* 2026-09-30 follow-up #2 — the gap came back on a real phone (Android
   Chrome) even after the previous fix (`.talkLog` genuinely bounding its
   own height instead of growing to fit the whole conversation) verified
   cleanly in headless Chrome. That bound was correct and is still in
   place below; what was wrong was how `.talkInputArea` was made to
   track the rail. Two things were tried and both turned out incomplete:
   a JS-measured pixel gap (`--talkLogMaxHeight`, correct only at the
   instant it's measured — stale the moment Chrome for Android's toolbar
   moves, which headless Chrome, having no toolbar, never caught); and
   `position: sticky` alone (reachable through this file's own history —
   `git log -p`), which turned out to have its OWN gap: sticky only
   takes effect once the page actually scrolls PAST the stuck point, and
   once `.talkLog` stopped growing without limit, the whole page is
   often *shorter* than the viewport (little conversation yet, or a
   short one after RESET) — nothing scrolls, so a merely-sticky input
   area just sits at whatever height its own content naturally reaches,
   nowhere near the rail, with a large visible gap. Caught live, driving
   a real toolbar-height change in headless Chrome via
   `Emulation.setDeviceMetricsOverride` (spike/…/verify_talk_gap.mjs):
   the gap didn't budge at all when the simulated viewport height
   changed, which is what a "just sitting at its static position, sticky
   never engaged" bug looks like — a genuinely different failure mode
   from either previous one.

   The actual fix: don't answer "is the input area in the right place"
   with EITHER of those (a JS number, or a scroll-dependent CSS trick).
   Answer it with plain flex-fill, the same tool `main`'s own `flex: 1`
   already uses to reach the bottom of `body` (itself forced to at
   least the full viewport height by `min-height: 100%` on `html`/
   `body`). `#talkPanel`/`.talkPanelSection`/`.talkLog` below now chain
   that same `flex: 1; min-height: 0` down one more level each: `.talkLog`
   grows to fill *exactly* whatever vertical space is actually left
   between the panel's own top and `main`'s own bottom edge — which
   `main`'s own `padding-bottom` reserves clear of the fixed
   `.safetyRail` for every tab, not just this one (see `main`'s own
   comment: that reserve is now a live-measured `--safetyRailHeight`,
   js/main.js, not a guessed constant — a guess turned out to be exactly
   the same "gap that doesn't track the real rail" bug one layer up, once
   this flex chain made the reserved amount load-bearing rather than
   merely generous). `.talkInputArea`, a plain (non-growing) flex item
   straight after it, therefore always lands exactly at the bottom of
   that filled column — which IS just above the rail — with no
   scroll-distance threshold to clear, and (within THIS file's own
   layout math) no JS number to go stale from viewport movement: unlike
   the rail's own height, "where is the bottom of `main`" is derived
   purely from `flex: 1`/`min-height: 100%`, recomputed by the browser's
   own layout engine on every layout pass, the same continuous, native
   recomputation `position: fixed`/`sticky` get, but without sticky's
   "only once you've scrolled past it" gap. `min-height: 0` at every
   level is the one non-obvious part —
   its ABSENCE is the well-known flexbox trap that caused the ORIGINAL
   bug (a flex item's default `min-height: auto` refuses to shrink below
   its own content size, which is exactly how `.talkLog` grew to fit the
   entire conversation instead of being clipped and scrolling
   internally); explicitly zeroing it at each level is what lets
   `overflow-y: auto` actually take effect instead of losing that fight
   silently. */
.talkInputArea {
  background: var(--panel);
  padding-top: 8px;
}

#talkPanel {
  display: flex;
  flex-direction: column;
  flex: 1;
  min-height: 0;
  /* Defense in depth for a genuinely tight viewport (a short landscape
     phone, mainly): `.talkLog` already shrinks freely (`flex: 1;
     min-height: 0`), but it still has its OWN practical floor — a line
     or two of text — below which it can't usefully shrink further. On a
     tall-enough screen that never matters; caught live on a 915×412
     simulated landscape phone, `.panelLabelRow`'s + `.talkInputArea`'s
     own (non-shrinking) natural heights plus `.talkLog` already crushed
     to its floor together still exceeded the space `main`'s own
     `padding-bottom` reserves for the rail — and with nothing here to
     catch it, that excess overflowed straight past `.talkPanelSection`'s
     own bottom edge, into the rail's own space (the exact "overlap" this
     whole fix exists to prevent, just from too little room rather than a
     stale measurement). `overflow-y: auto` here means that excess
     becomes an internal scrollbar on the PANEL instead — everything
     stays contained above the rail, worst case at the cost of a second,
     nested scroll (this panel, then `.talkLog` inside it) rather than
     ever bleeding onto the SafetyRail's own controls. */
  overflow-y: auto;
}

.talkLog {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 13px;
  line-height: 1.5;
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 10px 12px;
  margin-bottom: 10px;
  /* The chat log is the one place on this page where the user is meant
     to actually read/select text — unlike every button/fader elsewhere,
     it opts back into normal touch/selection behaviour. */
  user-select: text;
  -webkit-user-select: text;
}

.talkLine {
  margin-bottom: 8px;
  white-space: pre-wrap;
  word-break: break-word;
}

.talkLine-user::before {
  /* "youse", not "you" — Talk to Lights is a shared conversation
     (2026-09-30 follow-up: visible to everyone, not just whoever sent
     it), so a turn's own "you" is misleading — it could be any paired
     device, not only the one reading it right now. */
  content: "youse  ";
  color: var(--text-dim);
}

.talkLine-agent {
  color: var(--agent-text);
}

.talkLine-agent::before {
  content: "blx  ";
  color: var(--agent-label);
}

.talkLine-system {
  color: var(--text-dim);
  font-style: italic;
}

/* 2026-10-01 follow-up — the opening banner (js/talk.js's own doc
   comment on `LOGO_FULL`/`LOGO_COMPACT`: parity with bizarroCLI's own
   REPL banner, Sources/bizarroCLI/Banner.swift). `.talkLogoFull` is 54
   monospace columns; at this panel's real chrome (main's 16px + .panel's
   14px + .talkLog's 12px, each side — 84px total) and `.talkLog`'s own
   13px font, that needs very roughly 520px of viewport to clear without
   overflowing. 560px leaves it a real margin rather than cutting it
   close, so every actual phone gets the compact swap and everything
   bigger (a small tablet included) gets the real art. */
.talkBanner {
  margin-bottom: 12px;
  /* The ASCII logo and its "may your imagination run free" line are the
     agent introducing itself, so they speak in the agent's own colour
     (2026-10-01 follow-up) rather than the dim grey the page uses for
     incidental system text. Inherited by `.talkLogoFull`/`.talkLogoCompact`
     and `.talkBannerPrompt` below, which set no colour of their own. */
  color: var(--agent-text);
}

.talkLogoFull,
.talkLogoCompact {
  margin: 0 0 8px;
  font-family: inherit;
  white-space: pre;
}

.talkLogoCompact {
  display: none;
}

@media (max-width: 560px) {
  .talkLogoFull { display: none; }
  .talkLogoCompact { display: block; }
}

/* The compact art is 32 columns (js/talk.js's own note), which measures 250px
   at the log's 13px monospace — 18px more than a 320px phone's ~232px of
   content width, the one size where it spills into a horizontal scroll.
   Shrinking the art by a point there, rather than redrawing it to ~29
   columns, keeps one piece of artwork for every phone; the swap-don't-scale
   rule above is about parity with the CLI's own two banners, and this scales
   neither of those — it rescues the compact one at a width no modern handset
   reports. Measured at 320/360/375/390/414/560. */
@media (max-width: 359px) {
  .talkLogoCompact { font-size: 12px; }
}

.talkBannerPrompt {
  font-style: italic;
}

/* 2026-09-30 follow-up — a simple loading indicator for "the duration
   between request and CLI response" (`GeminiPlanner`'s own real call,
   js/talk.js's own `showThinkingLine()`): five dots, pulsing in a
   staggered wave rather than all together, the same reading as any
   ordinary chat app's own "typing…" indicator. Pink (`--agent-text`,
   the same tone the agent's own reply text already uses) since this
   dot row IS standing in for that reply, before it exists yet. */
.talkLine-thinking {
  display: flex;
  align-items: center;
  gap: 5px;
  padding: 6px 0;
}

.talkThinkingDot {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--agent-text);
  animation: talkThinkingPulse 1.2s ease-in-out infinite;
}

.talkThinkingDot:nth-child(1) { animation-delay: 0s; }
.talkThinkingDot:nth-child(2) { animation-delay: 0.12s; }
.talkThinkingDot:nth-child(3) { animation-delay: 0.24s; }
.talkThinkingDot:nth-child(4) { animation-delay: 0.36s; }
.talkThinkingDot:nth-child(5) { animation-delay: 0.48s; }

@keyframes talkThinkingPulse {
  0%, 60%, 100% { opacity: 0.25; transform: scale(0.8); }
  30% { opacity: 1; transform: scale(1.15); }
}

/* Transient, local-only feedback (js/talk.js's own doc comment on why
   this is never part of the shared log) — sits between the log and the
   input, same rhythm as the page-wide `.error-banner`. */
.talkNotice {
  font-size: 12px;
  color: var(--warn);
  margin-bottom: 8px;
}

.talkForm {
  display: flex;
  gap: 8px;
}

.talkInput {
  flex: 1;
  background: var(--bg);
  border: 1px solid var(--border-strong);
  border-radius: 10px;
  color: var(--text);
  font-size: 15px;
  padding: 10px 12px;
  min-width: 0;
}

.talkInput:focus {
  outline: none;
  border-color: var(--accent);
}

.talkSendBtn {
  background: var(--accent);
  border: none;
  border-radius: 10px;
  color: var(--on-accent);
  font-size: 14px;
  font-weight: 600;
  padding: 10px 16px;
}

.talkSendBtn:disabled { opacity: 0.6; }

/* The "keep, or turn off?" row's styles went with the row itself
   (2026-10-01 follow-up — js/talk.js's own note on why). Both answers are
   ordinary utterances now; nothing on this page renders them as buttons. */

/* Talk to Lights history (§6) — reuses .pairingOverlay/.pairingCard
   (index.html's own comment on why: "anything that needs a full-screen
   transient state"), widened and left-aligned since this one holds a
   scrollable list of real content rather than a short form. */

.talkHistoryCard {
  max-width: 420px;
  max-height: 80vh;
  display: flex;
  flex-direction: column;
  text-align: left;
}

.talkHistoryList {
  overflow-y: auto;
  margin-bottom: 14px;
}

/* One per `conversationID` (js/talk.js's own doc comment — "history
   should be visible to all users," grouped by reset boundary, not by
   browser). */
.talkHistoryGroup + .talkHistoryGroup {
  margin-top: 16px;
}

.talkHistoryGroupHeading {
  font-family: var(--legend);
  font-size: 11px;
  font-weight: 400;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--accent);
  margin-bottom: 4px;
}

.talkHistoryRow {
  border-top: 1px solid var(--border);
  padding: 10px 0;
}

.talkHistoryRow:first-child { border-top: none; }

.talkHistoryUtterance {
  font-weight: 600;
  font-size: 13px;
}

.talkHistorySay {
  color: var(--text-dim);
  font-size: 12px;
  margin-top: 2px;
}

.talkHistoryTime {
  color: var(--text-dim);
  font-size: 10.5px;
  margin-top: 4px;
}

.talkHistoryDoBtn {
  margin-top: 8px;
  background: var(--accent);
  border: none;
  border-radius: 8px;
  color: var(--on-accent);
  font-size: 12px;
  font-weight: 600;
  padding: 6px 12px;
  cursor: pointer;
}

/* RESET confirmation popup (2026-09-30 follow-up) — Cancel/confirm side
   by side rather than `.pairingSubmitBtn`'s own default full-width stack,
   so neither reads as the obvious default action to tap without reading
   the warning above it first. */
.talkResetConfirmBtns {
  display: flex;
  gap: 8px;
}

.talkResetConfirmBtns .pairingSubmitBtn {
  width: auto;
  flex: 1;
}

.talkResetCancelBtn {
  background: var(--bg);
  color: var(--text);
  border: 1px solid var(--border-strong);
}

/* ===========================================================================
   Phone layout (2026-10-01) — the livecam is DOCKED, not floating.

   The bug this exists for: `.livecamWidget` above is `position: fixed`, 168px
   wide, anchored top-right below `--livecamTopGap` — a gap that measures
   `.controlBanner` and nothing else. `.tabBar` sits in normal flow under that
   banner with no stacking context of its own, so on a phone the widget landed
   squarely on top of the "Talk to Lights" tab: you could not see that Talk was
   an option at all unless you already knew to minimize or drag the cam away.

   Fixed by changing what the widget IS at phone width rather than measuring
   one more element and carrying on floating it (which only moves the same
   collision somewhere else): `position: static` drops it into normal flow,
   directly below the tabs, where js/livecam-widget.js mounts it. It then
   cannot cover anything, by construction, and the page reads straight down —
   banner, status, tabs, cam, controls, rail.

   `.expanded` (the half-window split) is excluded throughout: that state is
   deliberately an overlay, with `<body>`'s own padding reflowing the page
   around it, and keeps its existing `position: fixed` path untouched.

   560px is this file's existing phone breakpoint (the `.talkLogoFull` swap
   above) — the width this page's single-column rhythm is already built on. */

@media (max-width: 560px) {
  /* The one place "this is the phone layout" is decided. js/livecam-widget.js
     reads this back (`getComputedStyle(document.documentElement)`) instead of
     carrying its own copy of 560px, so the breakpoint lives here and only
     here — and the widget can still answer "am I docked?" correctly while
     expanded, which asking its own `position` cannot (expanded is `fixed` in
     both layouts). */
  :root {
    --phoneLayout: 1;
  }

  /* `<header>` goes entirely (2026-10-01 follow-up: "we need more screen real
     estate"). Nothing in it survives contact with a phone: "BLX Remote" is a
     title on a page with nothing to confuse it with, and the connection state
     — the one genuinely live thing it held — moves into the banner as a dot
     beside the control button. (The "Nothing selected" line it also carried
     has since gone from every width, not just this one — js/remote.js's own
     header comment.)
     That is ~45px of chrome back, on every scroll position rather than only
     once scrolled, and it is what lets the tabs hang straight off the banner
     with nothing in between. */
  header {
    display: none;
  }

  .controlBannerDot {
    display: flex;
  }

  /* Sticky, directly beneath the sticky banner: switching between Buttons and
     Talk stays one tap away however far the deck underneath has been
     scrolled. `--controlBannerHeight` is measured and published by
     js/control.js (that file's own comment on why it has to be a border-box
     measurement); the `0px` fallback is the no-banner case — the connect and
     pairing screens, where that variable is deliberately absent.

     z-index 94 is the number that matters here: below the banner's 95, which
     must always win since it owns the request/relinquish button, and above the
     docked cam, which is in flow and so paints in document order. The opaque
     background is not decoration either — this bar's own 10px top padding and
     the 6px gap between its two buttons would otherwise show scrolling
     content straight through a bar whose whole job is to stay pinned above
     it. */
  .tabBar {
    position: sticky;
    top: var(--controlBannerHeight, 0px);
    z-index: 94;
    background: var(--bg);
    /* No top padding: the tabs hang straight off the banner's own bottom
       edge, one piece of chrome rather than two things with a gap. */
    padding-top: 0;
  }

  /* Notches hanging off the banner, so the corners round the way they're
     attached: square at the top, where they meet the banner's own bottom
     border, rounded below. The top border goes with them — kept, it would
     draw a second line 1px under the banner's, which is exactly the seam
     this is getting rid of. */
  .tabBtn {
    border-radius: 0 0 10px 10px;
    border-top: none;
  }

  /* Docked mini view. `position: static` retires the entire floating
     arrangement in one line: the `[data-corner]` top/right/bottom/left rules
     and the z-index simply stop applying to a static box, with nothing to
     unset one at a time. Width stays the 168px the base rule already sets — a
     thumbnail, deliberately not the full column, so DECK's strips and Talk's
     log keep the room they need below it.

     `touch-action` is the part that is NOT optional: it's `none` on both drag
     handles above (the browser's own pan gesture otherwise competes with the
     drag), and js/livecam-widget.js refusing to START a drag while docked does
     nothing about that on its own — left alone, the docked cam would be a dead
     zone that swallows scroll gestures instead of passing them to the page. */
  .livecamWidget:not(.expanded) {
    position: static;
    /* Never absorb flex shrink. `<body>` is a flex column with a definite
       height, so once `main` grows to a long deck's real height (the
       `body[data-tab="buttons"]` rule above) there is large negative free
       space, and every sibling with a non-zero shrink factor pays for it.
       This one has no floor to stop at: `.livecamVideoWrap` sizes itself by
       `aspect-ratio` off a video with no intrinsic size, so its min-content
       height is 0 and the automatic minimum can't protect it. Measured
       before this line: a 2px-tall widget clipping 155px of perfectly
       healthy bar-plus-video, on the Buttons tab only, and only once the
       deck was long enough to overflow. The banner and tab bar are safe
       without it — their own text gives them a real min-content height. */
    flex: none;
    /* Centred (2026-10-01 follow-up) — the auto side margins are what do it:
       a definite 168px width leaves real free space in the cross axis, and a
       flex item's auto margins absorb it evenly. The minimized row below has
       to put explicit margins back for exactly that reason. */
    margin: 10px auto 0;
    box-shadow: none;
  }

  .livecamWidget:not(.expanded) .livecamWidgetBar,
  .livecamWidget:not(.expanded) .livecamMiniBubble {
    cursor: default;
    touch-action: auto;
  }

  /* Minimized, docked: a narrow full-width row — collapsed, but visibly
     re-openable — rather than the 44px dot the floating widget shrinks to. A
     lone dot in a corner reads as debris on a page that is otherwise a
     vertical stack; a labelled row reads as something you can open, and gives
     the dot a place to go on meaning what it means.

     The explicit side margins undo the centring above, and are what make this
     a row at all: an auto cross-axis margin cancels a flex item's
     `align-self: stretch` outright (flexbox §9.4), so `width: auto` under
     `margin: auto` would shrink-wrap to the label instead of spanning. */
  .livecamWidget.minimized:not(.expanded) {
    width: auto;
    height: auto;
    border-radius: 10px;
    margin-left: 16px;
    margin-right: 16px;
  }

  .livecamWidget.minimized:not(.expanded) .livecamMiniBubble {
    width: 100%;
    height: auto;
    justify-content: flex-start;
    gap: 10px;
    padding: 10px 12px;
    cursor: pointer;
  }

  .livecamWidget.minimized:not(.expanded) .livecamMiniLabel {
    display: block;
    font-family: var(--legend);
    font-size: 11px;
    font-weight: 400;
    letter-spacing: 0.1em;
    text-transform: uppercase;
    color: var(--text-dim);
  }

  .livecamWidget.minimized:not(.expanded) .livecamMiniHint {
    display: block;
    margin-left: auto;
    font-size: 13px;
    color: var(--text-dim);
  }

  /* Expanded, on a phone: everything between the control banner and the GM
     rail (2026-10-01 follow-up) — not the half-window split the wider layout
     uses. Both edges are the same measured variables the rest of this layout
     already runs on (js/control.js's banner, js/main.js's rail), so the video
     tracks a collapsing banner and a safe-area-inset rail without this rule
     knowing either height.

     Nothing is covered by stacking that isn't covered by geometry: this stops
     short of both bars rather than sitting under them. z-index 96 exists only
     for the sticky tab bar (94), which this DOES cover and which would
     otherwise paint its two buttons over the video; .pairingOverlay's 100
     still owns the page when it's up.

     The orientation classes are named alongside the bare `.expanded` so a
     stale one — left by an expand at a wider width, before a rotate or a
     resize crossed the breakpoint — can't out-specify this and park the
     video back at 50vh. js/livecam-widget.js clears them on a phone anyway;
     this makes that belt-and-braces rather than load-bearing. */
  .livecamWidget.expanded,
  .livecamWidget.expanded.livecamExpandedLandscape,
  .livecamWidget.expanded.livecamExpandedPortrait {
    top: var(--controlBannerHeight, 0px);
    right: 0;
    bottom: var(--safetyRailHeight, 0px);
    left: 0;
    width: auto;
    height: auto;
    z-index: 96;
    border-radius: 0;
  }
}
