:root {
  color-scheme: dark;
  --bg-0: #050711;
  --bg-1: #0b1028;
  --bg-2: #151036;
  --glass: rgba(28, 36, 76, 0.48);
  --glass-strong: rgba(42, 54, 112, 0.62);
  --glass-soft: rgba(255, 255, 255, 0.075);
  --glass-edge: rgba(193, 213, 255, 0.34);
  --glass-edge-strong: rgba(232, 239, 255, 0.58);
  --text: #f6f8ff;
  --muted: #b6c2ea;
  --dim: #8190c6;
  --accent: #a06cff;
  --accent-2: #62c7ff;
  --accent-3: #d9b7ff;
  --danger: #ff658a;
  /* Darker stop for the session-ending button gradient below (.aero-button.
     signout) - --danger alone is a single color, and a gradient needs two. */
  --danger-strong: #7a1030;
  --success: #65f0c5;
  --warning: #ffd36a;
  --shadow: rgba(0, 0, 0, 0.5);
  --radius-xl: 1.4rem;
  --radius-lg: 1rem;
  --radius-md: 0.65rem;
  /* Item 11 (motion-symmetry pass, symmetry & cleanliness normalization): the brief that
     commissioned this pass asked for exactly `--radius-lg: 16px; --radius-md: 0.7rem;
     --radius-sm: 0.65rem; --radius-pill: 999px`, written without knowledge that
     `--radius-xl`/`--radius-lg`/`--radius-md` above already existed (added by earlier,
     independent work) at 1.4rem/1rem/0.65rem -- 1rem already equals the brief's own "16px" for
     `--radius-lg` (same value, rem vs px), so nothing there actually needed changing. Reusing
     the brief's literal `--radius-md: 0.7rem` would have silently changed the *existing*
     `--radius-md` token's value from 0.65rem to 0.7rem at all 13 of its current call sites --
     a real, unintended visual change item 11 explicitly forbids ("zero visual change intended").
     `--radius-sm` below instead takes the one value in the brief's set with no existing token at
     all (0.7rem, previously a bare literal used exactly once) -- the "sm" name no longer implies
     strictly smaller than `--radius-md` (0.7rem > 0.65rem) purely because these two tokens come
     from two different eras of this scale, not from any design intent; --radius-pill (999px) is
     new outright, previously used as a bare literal at every pill/circle-corner control in this
     file. See this pass's own report for the full reasoning; scripts/measure-visual-regression.mjs
     confirmed zero pixel delta from this token substitution alone. */
  --radius-sm: 0.7rem;
  --radius-pill: 999px;
  /* Item 12 (motion-symmetry pass, inset/gap audit): the room page's corner-pill/panel controls
     (host label, go-live button, grip, video-stats toggle/panel, live-edge
     pill, playback scrubber, PiP toggle, and `.chat-panel`'s own desktop inset) already agreed on
     0.75rem for their corner offset before this pass -- this token just names that existing
     agreement, zero visual change. `.chat-toggle-collapse` ("Hide chat") was the one outlier, at
     0.6rem, predating those controls; normalized to this same token below (a real, small, ~2.4px
     nudge - see its own rule's comment) rather than left as a second, unnamed corner-offset
     value with no other reason to differ. */
  --inset-chrome: 0.75rem;
  /* `.chat-panel`'s own padding value (chat-toggle-alignment fix), named so a sibling outside the
     panel - `.chat-toggle-collapse` below is the one that needs it - can align to the exact same
     inset without duplicating the clamp() expression a second time and risking the two drifting
     apart independently, the same reason `--room-titlebar-height` is a shared token rather than a
     value repeated at each of its call sites. */
  --chat-panel-inset: clamp(1rem, 2.5vw, 1.35rem);
  /* Motion tokens (playback-controls bundle) */
  --motion-fast: 120ms;
  --motion-base: 180ms;
  --motion-slow: 280ms;
  --ease-out: cubic-bezier(0.2, 0, 0, 1);
  --ease-in: cubic-bezier(0.4, 0, 1, 1);
}

* { box-sizing: border-box; }

html { min-height: 100%; }

body {
  margin: 0;
  min-height: 100vh;
  font-family: "Segoe UI", "Segoe UI Variable", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
  color: var(--text);
  background:
    radial-gradient(circle at 16% 8%, rgba(112, 82, 255, 0.45), transparent 26rem),
    radial-gradient(circle at 86% 14%, rgba(79, 191, 255, 0.34), transparent 24rem),
    radial-gradient(circle at 50% 105%, rgba(126, 48, 255, 0.26), transparent 30rem),
    linear-gradient(135deg, var(--bg-0) 0%, var(--bg-1) 42%, var(--bg-2) 100%);
  background-attachment: fixed;
}

body::before {
  content: "";
  position: fixed;
  inset: 0;
  pointer-events: none;
  background:
    linear-gradient(rgba(255,255,255,0.035) 1px, transparent 1px),
    linear-gradient(90deg, rgba(255,255,255,0.025) 1px, transparent 1px);
  background-size: 42px 42px;
  mask-image: linear-gradient(to bottom, rgba(0,0,0,0.85), transparent 82%);
}

body::after {
  content: "";
  position: fixed;
  inset: 0;
  pointer-events: none;
  background: linear-gradient(115deg, transparent 0 34%, rgba(255,255,255,0.08) 43%, transparent 54% 100%);
  mix-blend-mode: screen;
  opacity: 0.48;
}

a {
  color: #b9dcff;
  text-decoration: none;
}

a:hover { color: white; text-decoration: underline; }

h1, h2, h3, p { margin-top: 0; }

h1 {
  margin-bottom: 0;
  font-size: clamp(2rem, 5vw, 4.25rem);
  letter-spacing: -0.055em;
  text-shadow: 0 2px 0 rgba(0,0,0,0.45), 0 0 22px rgba(160,108,255,0.58);
}

h2 {
  margin-bottom: 0.8rem;
  font-size: clamp(1.25rem, 2vw, 1.65rem);
  letter-spacing: -0.025em;
}

h3 { margin-bottom: 0.65rem; }

code {
  display: inline-block;
  max-width: 100%;
  overflow-wrap: anywhere;
  color: #e6f5ff;
  background: rgba(3, 8, 24, 0.42);
  border: 1px solid rgba(165, 190, 255, 0.22);
  border-radius: 0.45rem;
  padding: 0.12rem 0.38rem;
  box-shadow: inset 0 1px rgba(255,255,255,0.08);
}

/* No window box (theater-as-default wave 2): this padding used to be the
   visible "empty ground" margin around a bordered `.aero-window` box - see
   that rule below, and docs/comet/changes/theater-as-default/brief.md
   ("Why the page is not a window in a field"). Wave 1 already zeroed it for
   the room page alone via `.room-shell` (below); every other page now gets
   the same treatment unconditionally, since a form console gains nothing
   from a margin whose only job was separating it from a frame that no
   longer exists. `.aero-titlebar`'s own horizontal padding and
   `.aero-content`'s padding (both unchanged) are what keep page content off
   the literal viewport edge now - the same "internal padding, not an outer
   shell inset" split the room page already uses. `.room-shell` below still
   declares its own `padding: 0` explicitly even though it is now redundant
   with this base value - left in place deliberately (it is room-specific
   CSS, out of scope for this change) rather than removed as dead weight. */
.aero-shell {
  position: relative;
  z-index: 1;
  min-height: 100vh;
  padding: 0;
}

.auth-shell {
  display: grid;
  place-items: center;
  /* `isolation: isolate` forces a stacking context on this element unconditionally, not merely
     as a side effect of `.aero-shell`'s own `position: relative` + `z-index: 1` above (which
     already creates one today, but only incidentally - nothing before this made that a
     deliberate, load-bearing guarantee). `.auth-ghost-grid`'s z-index:-1 vs. `.auth-window`'s
     explicit z-index:1 (both further down this file) are meaningless numbers without a shared,
     guaranteed stacking-context parent to be negative/positive *within* - found by actually
     rendering login.html and cropping into the result: ghost-tile text ("LIVE", "Nyx") was
     reading crisp and legible inside the window's own content area, not blurred behind its
     `backdrop-filter`, proving the tile was compositing in front of (or at least not behind)
     the window despite the z-index values looking correct on paper. `isolation: isolate` +
     explicit integer z-index on both the ghost grid and the window (not `auto`) removes that
     ambiguity outright. */
  position: relative;
  isolation: isolate;
  /* login-gate-design-spec's "Shared Tokens" section: a handful of the room page's own
     `.room-shell` custom properties (see that rule's own block above) are exactly the values
     the auth-page redesign below needs (--btn-lit/--btn-lit-strong are byte-for-byte the two
     buttons' gloss layers; --blur-panel is byte-for-byte the window's backdrop-filter;
     --edge-soft is byte-for-byte the gate divider's color). Redeclared here rather than moved
     to :root or added to `.room-shell` itself, so the room page's own token block stays
     untouched and this stays legible as "what the auth pages borrow", not a silent global
     rename. The mobile card radius (16px) reuses the existing `--radius-lg` token from :root
     instead of adding a redundant one here. */
  --btn-lit: linear-gradient(180deg, rgba(255,255,255,.24), rgba(255,255,255,.04) 50%, rgba(255,255,255,0) 52%);
  --btn-lit-strong: linear-gradient(180deg, rgba(255,255,255,.28), rgba(255,255,255,.05) 50%, rgba(255,255,255,0) 52%);
  --edge-soft: rgba(193,213,255,.16);
  --blur-panel: blur(18px) saturate(1.3);
}

/* The dashboard (admin/host) shell sizes itself to its own content, never
   to the browser window: no viewport-percentage height units and no
   forced hidden overflow here. `align-items: flex-start` pins
   `.aero-window` to the top of the shell rather than stretching it to
   fill a viewport-derived height, so a short page does not get
   artificially inflated and a tall one simply grows the page - the page
   then scrolls normally. See docs/comet/changes/room-layout-redesign/
   specs/room-view-presentation/spec.md ("Height comes from content, never
   from the viewport"). The room shell used to follow the same rule, but
   room-adaptive-fill deliberately reversed it there (see the `.room-shell`
   rule immediately below, which overrides `align-items` back to
   `stretch`): the video can only be bounded by the height actually
   available if something in the chain is willing to reference the
   viewport, and the room page is where that trade was made. Nothing here
   changes for the dashboard shell. */
.dashboard-shell,
.room-shell {
  display: flex;
  align-items: flex-start;
  justify-content: center;
}

/* Reverses "no viewport-derived sizing" for the room page only (the
   dashboard/admin/login/gate shells above are untouched) - see "Reverse
   'no viewport-derived sizing on desktop'" in docs/comet/changes/
   room-adaptive-fill/brief.md. Filling the display means knowing how much
   of it exists, and nothing can learn that without the viewport. `dvh`
   (declared second so it wins where supported, with `vh` first as the
   fallback for browsers without it) tracks the *actual* visible viewport
   height, including once mobile browser chrome retracts - a static `vh`
   would describe a taller area than is visible and hide the composer
   under the address bar. `align-items: stretch` overrides the shared rule
   above so `.room-window` claims that whole height instead of sizing to
   its own content, which is what finally gives the video below a real
   height budget - not just a width - to be bounded by. This one rule now
   covers both the >900px and <=900px cases; previously it existed only
   inside the narrow-viewport media query below, because only the narrow
   layout needed a real height to fit into. */
.room-shell {
  height: 100vh;
  height: 100dvh;
  /* `.aero-shell`'s `min-height: 100vh` must not win here: on Android Chrome with the URL bar
     showing, 100vh (the large viewport) is taller than 100dvh (the visible one), so the page grew
     past the screen and scrolled by the bar's height - the titlebar scrolled away with it
     (v1.23.0 on a real phone). Playwright cannot see this: its viewport has no dynamic browser UI,
     so vh == dvh in every harness; room_mobile_layout_test.go guards the declaration textually. */
  min-height: 100dvh;
  align-items: stretch;
  padding: 0;
  background:
    radial-gradient(1100px 620px at 12% -5%, rgba(160,108,255,.26), transparent 62%),
    radial-gradient(900px 560px at 92% 8%, rgba(98,199,255,.18), transparent 60%),
    radial-gradient(1200px 640px at 45% 112%, rgba(120,60,220,.32), transparent 62%),
    linear-gradient(180deg, #0b1028, #050711);
  background-attachment: fixed;
}

/* Design-pass 1 recipes shared by the room page's chrome */
.room-shell {
  padding: 0;
  --room-pad: 12px;
  --room-gap: 12px;
  --glass-lit: linear-gradient(180deg, rgba(255,255,255,.14), rgba(255,255,255,.02) 50%, rgba(255,255,255,0) 52%);
  --btn-lit: linear-gradient(180deg, rgba(255,255,255,.24), rgba(255,255,255,.04) 50%, rgba(255,255,255,0) 52%);
  --btn-lit-strong: linear-gradient(180deg, rgba(255,255,255,.28), rgba(255,255,255,.05) 50%, rgba(255,255,255,0) 52%);
  --edge: rgba(193,213,255,.3);
  --edge-soft: rgba(193,213,255,.16);
  --edge-lit: rgba(232,239,255,.42);
  --ink: rgba(5,7,17,.62);
  --ink-soft: rgba(5,7,17,.45);
  --ink-faint: rgba(5,7,17,.35);
  --blur-panel: blur(18px) saturate(1.3);
  --blur-chrome: blur(10px);
  --accent-gradient: linear-gradient(90deg, #a06cff, #62c7ff);
  --radius-panel: 16px;
  --radius-card: 12px;
  --pill-h: 30px;
}

/* `padding: 0` above is belt-and-suspenders with the shared `.aero-shell`
   rule, which theater-as-default wave 2 already zeroes for every page (see
   its own comment) - kept so the room page's zero-padding guarantee never
   depends solely on the shared rule. Used to be conditional
   (`.theme-theater.aero-shell { padding: 0 }`) back when Theater was a
   second, opt-in look; now that the room page has exactly one look, it's
   unconditional here instead. */

/* No window box, no width cap (theater-as-default wave 2): this used to be
   a bordered, backdrop-blurred frame capped at `min(1220px, 100%)` - the
   single rule the brief calls out as "most responsible for the boxed-in
   feel on a wide display" (docs/comet/changes/theater-as-default/brief.md).
   Wave 1 already zeroed all of this for the room page alone via
   `.room-window` below; every other page now gets the same treatment
   unconditionally. What still visually separates page content from the
   plain body ground is `.aero-titlebar` (a thin glass strip) and each
   `.aero-panel`/`.dashboard-card` (translucent, bordered, backdrop-blurred
   individually) - glass panels layered over the ground, not one big window
   everything sits inside. `.auth-window`'s own `max-width` immediately
   below is the one deliberate exception: a login form or the room gate is
   a small centered task, not a console that benefits from the full display
   width, so it keeps a width cap - just a cap on a plain block now, not a
   bordered frame. */
.aero-window {
  width: 100%;
}

/* login-gate-design-spec: login keeps the small 440px "task card" width theater-as-default
   wave 2 originally gave every auth page (see the comment this replaces, still true in spirit);
   the gate's new two-pane layout (`.gate-content`, further down) needs real room for a
   340px-wide streamer pane alongside a legible guest pane, so it gets the wider 920px card
   instead. `:has(.gate-content)` reads "this is the gate page" off the page's own markup
   (`gate.html`'s content section carries that class; `login.html`'s never does) rather than a
   class on `.auth-window` itself, which the shared-rules brief locks to an exact literal string
   on both templates. */
.auth-window:not(:has(.gate-content)) { max-width: 440px; }
.auth-window:has(.gate-content) { max-width: 920px; }

/* login-gate-design-spec's "Main Window Card": unlike every other page, the auth window is
   meant to read as one glass card (titlebar and content together), not a bare max-width block
   with the glass living only on the panel(s) inside it - see the reset on `.auth-panel`/
   `.auth-window .gate-panel` further down, which stops those inner panels from doubling this
   same recipe now that the window itself carries it. This is a deliberate, scoped exception to
   theater-as-default wave 2's "no window chrome" rule immediately above (`.aero-window`): a
   sign-in/room-gate page is exactly the "small centered task" that rule already carved out via
   `.auth-window`'s own max-width, and the approved design spec asks for the frame explicitly.
   Nothing here touches `.room-window`/`.dashboard-window`/the plain `.aero-window` base rule -
   every other page stays full-bleed. */
.auth-window {
  position: relative;
  /* Explicit, not `auto`: paired with `.auth-ghost-grid`'s `z-index: 0` and `.auth-shell`'s
     `isolation: isolate` (both see their own comments) so the window unambiguously paints in
     front of the ghost grid within that isolated stacking context, regardless of DOM order. */
  z-index: 1;
  overflow: hidden;
  border-radius: 18px;
  border: 1px solid var(--glass-edge);
  background: linear-gradient(180deg, rgba(255,255,255,.07), rgba(255,255,255,0) 30%), rgba(28,36,76,.55);
  box-shadow: inset 0 1px 0 rgba(232,239,255,.4), 0 30px 80px rgba(0,0,0,.55), 0 0 140px rgba(160,108,255,.16);
  backdrop-filter: var(--blur-panel);
  -webkit-backdrop-filter: var(--blur-panel);
  animation: ss-rise 280ms var(--ease-out) both;
}

/* The old inline-script `.aero-panel-enter` entrance (motion-symmetry pass, item 10) moved here
   as a pure-CSS animation on the window itself, per this pass's own shared-rules brief ("replace
   [the script] with a pure-CSS ss-rise animation on the window and delete the script in BOTH
   templates consistently"). Chosen over keeping the script because the window, not the inner
   panel, is now the card that visually enters (see the rule above) - animating the panel alone
   would leave the window's own border/shadow snapping in with no motion of its own. No-JS still
   renders the final visible state: this is a CSS animation with a `both` fill mode, not a
   script-applied class, so nothing depends on JavaScript running at all. */

/* Reset the shared `.aero-panel` glass on login's own panel: the window now carries the glass
   (see the rule above), so its one panel must sit flush inside it, at every viewport (login
   never removes the window frame - see the mobile media query further down, which only touches
   the gate page). The gate's own two panels get the equivalent reset too, but desktop-only (see
   the `@media (min-width: 901px)` block further down) - they revert to independent cards on
   mobile, once the shared window frame is gone there.

   `.auth-window .auth-panel` (two classes), not bare `.auth-panel`: the base `.aero-panel` rule
   further down this file is ALSO a single-class selector, so a bare `.auth-panel` reset ties its
   specificity exactly and then loses on source order alone, since `.aero-panel` is declared
   later in the file - found by actually rendering the page and inspecting computed style, not by
   reasoning about the cascade on paper (the login panel kept its border/background/shadow/16px
   radius in a real browser even with this "reset" in place). Bumping to two classes wins
   unconditionally on specificity instead of depending on where either rule happens to sit. */
.auth-window .auth-panel {
  border: none;
  border-radius: 0;
  background: none;
  box-shadow: none;
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
}

.auth-window .auth-panel::before {
  content: none;
}

/* Room gate's titlebar/eyebrow (login-gate-design-spec: 52px tall, eyebrow "VIEWER ROOM" visible
   above the room name) versus login's (44px, no eyebrow at all - see `.auth-titlebar-label`
   further down, used instead of an eyebrow+h1 pair). This used to be a blanket
   `.auth-titlebar .eyebrow { display: none }`, back when both pages hid it; login's markup no
   longer contains an eyebrow element at all, so nothing here needs to hide it for that page -
   only the gate page's `<p class="eyebrow">` (inside `.auth-titlebar-heading`) ever matches this
   selector now. */
.auth-titlebar .eyebrow {
  font-size: 0.68rem;
  letter-spacing: 0.14em;
  color: var(--dim);
  margin-bottom: 0.15rem;
}

.auth-titlebar-heading {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
}

.auth-titlebar-label {
  font-weight: 600;
  font-size: 0.75rem;
  line-height: 1;
  color: var(--muted);
}

.auth-titlebar h1 {
  font-size: 1.15rem;
  text-shadow: none;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.auth-titlebar .orb {
  width: 1.6rem;
  box-shadow: 0 0 14px rgba(109, 194, 255, 0.8), inset 0 1px 6px rgba(255,255,255,0.7);
}

/* Gate's titlebar is 8px taller than login's (52px vs 44px) and its orb is a plain static dot -
   login-gate-design-spec's login titlebar carries a breathing orb + ring + sheen instead (see
   the `:not(:has(.gate-content))` rules further down this file). */
.auth-window:has(.gate-content) .aero-titlebar {
  min-height: 3.25rem;
  padding: 0 20px;
}

.auth-window:has(.gate-content) .auth-titlebar .orb {
  width: 1.375rem;
}

/* No fixed width cap: `min(1480px, 100%)` measured at 43% of a 3440px-wide
   display and would have overflowed a 1366px-wide laptop had it been
   raised to suit the ultrawide instead - every fixed number is wrong on
   some display, so the cap is deleted rather than retuned. The window now
   simply takes the full width the shell offers; it is that width, not a
   magic number, that `.aero-player-stage` below is bounded by. `display:
   flex; flex-direction: column` (previously set only inside the
   narrow-viewport media query below) is now unconditional too: it turns
   `.room-window` into a two-row column - the header, then `.room-columns`
   - so the leftover height below the header is a real, definite quantity
   the video can also be bounded by, the same way the leftover width
   beside the chat column always has been. See docs/comet/changes/
   room-adaptive-fill/brief.md and specs/room-view-presentation/spec.md
   ("The video is the largest 16:9 box that fits"). */
.room-window {
  width: 100%;
  display: flex;
  flex-direction: column;
  /* Containing block for `.room-titlebar` (position: absolute, declared in
     the room-chrome section further down this file) and, at desktop
     widths, `.chat-column` (see the `@media (min-width: 901px)` block
     below) - both need a real positioned ancestor, not the viewport, so
     they stay anchored to the room window rather than the page.
     `--room-titlebar-height` is declared once here, read by both
     `.room-titlebar`'s own `min-height` and the desktop chat panel's `top`
     inset, so the two can never drift out of sync with each other the way
     two independently-chosen numbers could. */
  position: relative;
  --room-titlebar-height: 2.75rem;
  border: none;
  border-radius: 0;
  background: none;
  box-shadow: none;
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
}

/* No window chrome above is belt-and-suspenders with the shared
   `.aero-window` rule, which theater-as-default wave 2 already stripped to
   `width: 100%` for every page (see its own comment) - kept so this
   doesn't depend solely on the shared rule. Used to be conditional
   (`.theme-theater .aero-window`) back when Theater was a second, opt-in
   look; `.room-window` is the unconditional room-only hook now that
   there's only one. */

/* Cinema fullscreen (room.js/room-titlebar.mjs's toggleCinema) takes this
   element - the titlebar and `#room-columns` (video+chat) together, not
   `#room-columns` alone - so the titlebar (and everything that lives in it:
   viewer count, fit/fill, cinema itself, Leave/Log-out, and the auto-hide
   behavior above) stays a descendant of the fullscreened element and keeps
   working. The Fullscreen API detaches anything *outside* its target from
   rendering entirely; fullscreening `#room-columns` on its own would make
   the titlebar vanish outright the moment cinema mode started, with no way
   back out.

   `.room-window`'s own `background: none` above relies on `body`'s gradient
   showing through (see this rule's own comment) - that stops being true the
   instant this element is taken out of the page for fullscreen (there is no
   `body` behind it any more), so without an explicit background here
   fullscreen would show a black or transparent hole instead of the rest of
   the app's look. Reuses `body`'s own base gradient rather than inventing a
   new color, so cinema mode's ground matches everywhere else in the app;
   the radial accent gradients `body::before`/`body::after` layer on top are
   left out as unnecessary polish for a state that is mostly filled
   edge-to-edge by video and chat anyway. `::backdrop` is the pseudo-element
   the Fullscreen API paints *behind* the fullscreened element whenever it
   does not exactly fill the screen (e.g. mid-transition, or a display whose
   aspect ratio the element doesn't match) - set to the same base color as
   belt-and-suspenders with the element's own background immediately above,
   not a fallback path expected to matter in the ordinary case.

   NEEDS VISUAL CONFIRMATION: written without a browser available to
   actually enter fullscreen and look - get this checked by screenshot
   before relying on it looking right, not just compiling. */
.room-window:fullscreen {
  background: linear-gradient(135deg, var(--bg-0) 0%, var(--bg-1) 42%, var(--bg-2) 100%);
}

.room-window:fullscreen::backdrop {
  background: var(--bg-0);
}

/* `.room-titlebar`'s own layout (position: absolute, floating over the
   video rather than participating in this flex column at all) is declared
   later in this file, in the room-chrome section after the shared
   `.aero-titlebar`/`.dashboard-titlebar h1, .room-titlebar h1` rules below
   - it must come after them in source order to win on the properties it
   overrides (same selector specificity, so the later declaration wins),
   and grouping every room-specific chrome rule together there is easier to
   read than splitting the titlebar's treatment across two distant parts of
   this file. That absolute positioning is also a stronger guarantee than
   the "fixed natural height" this comment used to describe: an absolutely
   positioned element contributes zero height to its flex container, so
   `.room-columns` below always gets the *entire* height of `.room-window`,
   not merely a height `.room-titlebar` no longer competes for. */

/* Thin chrome (theater-as-default wave 2): titlebars are ~44px now, not
   the ~83px+ (`min-height: 5.2rem`) an in-flow window titlebar used to
   claim - see docs/comet/changes/theater-as-default/brief.md ("Chrome is
   thin... Titlebars are ~44px, not ~100px"). The numbers below are lifted
   directly from `.room-titlebar`'s own proven strip further down this file
   (min-height, vertical padding) rather than invented fresh, since that
   treatment already ships and already fits its content at this height.
   Unlike `.room-titlebar`, this base rule stays a normal in-flow block (no
   `position: absolute`) - admin/host/login/gate have real content below
   the titlebar, not a full-bleed video it would otherwise need to float
   over, so there is nothing here for it to overlay. `.room-titlebar`
   overrides every property below unconditionally (min-height, padding,
   background, border-bottom, box-shadow, plus position/backdrop-filter it
   adds itself) so the room page is unaffected by this shrink. */
.aero-titlebar {
  min-height: 2.75rem;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  padding: 0.35rem clamp(1.05rem, 3vw, 2rem);
  background:
    linear-gradient(180deg, rgba(246, 249, 255, 0.28), rgba(155, 190, 255, 0.12) 42%, rgba(35, 46, 96, 0.4) 43%, rgba(12, 18, 44, 0.48)),
    linear-gradient(90deg, rgba(120, 102, 255, 0.42), rgba(78, 182, 255, 0.18));
  border-bottom: 1px solid rgba(216, 231, 255, 0.35);
  box-shadow: inset 0 1px rgba(255,255,255,0.55), inset 0 -1px rgba(0,0,0,0.35);
}

.room-titlebar h1 {
  font-size: clamp(1.8rem, 3.4vw, 3.2rem);
}

/* Split from the rule above (theater-as-default wave 2): `.room-titlebar`
   overrides this size unconditionally further down the file regardless of
   what it says here, so keeping it in a shared selector never actually
   controlled the room page - it only ever set the dashboard pages' size.
   Now that the dashboard titlebar is ~44px instead of ~83px+, it needs its
   own much smaller number instead of inheriting one sized for the old
   in-flow window titlebar. */
.dashboard-titlebar h1 {
  font-size: clamp(1.1rem, 1.6vw, 1.4rem);
}

/* Thin chrome leaves no room for a three-line title stack (eyebrow, h1,
   "Version X") the old ~83px+ titlebar could afford - see
   docs/comet/changes/theater-as-default/brief.md. The eyebrow ("Control
   panel" / "Broadcast console") is dropped the same way `.room-titlebar`
   already drops its own ("Live room") for the identical reason: at this
   height it is the least essential of the three lines, and shrinking it
   further instead of hiding it would leave text too small to read. `h1`
   and the version line move from a stacked column onto one row instead of
   losing the version number outright - `admin.html`/`host.html`'s markup
   is untouched (still eyebrow, then h1, then version, in that order); only
   the layout of that existing markup changes. This is a restyle, not an
   information-architecture change - see the brief's non-goals. */
.dashboard-titlebar .eyebrow {
  display: none;
}

.dashboard-titlebar > div:first-child {
  display: flex;
  align-items: baseline;
  gap: 0.6rem;
  min-width: 0;
}

.dashboard-titlebar > div:first-child h1 {
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}

.dashboard-titlebar > div:first-child .muted {
  flex: 0 0 auto;
  white-space: nowrap;
}

/* Host's own extra titlebar tightening (measured at ~177px with
   scripts/measure-host-layout.mjs, back when the shared `.dashboard-
   titlebar` titlebar was still an in-flow ~83px+ window titlebar) is
   folded away as of theater-as-default wave 2: the shared `.aero-titlebar`/
   `.dashboard-titlebar h1` rules above are now already thinner than this
   page-specific override used to make them, so host no longer needs its
   own smaller numbers - it would otherwise end up *larger* than admin's
   now (this override predates the shrink above and was never updated to
   account for it). `host.html` still carries the `.host-titlebar` class in
   case a future host-specific need reappears, but no CSS currently keys
   off it - the same "unused hook left in markup on purpose" idiom
   `.dashboard-window` already used before this change. */

.aero-content {
  padding: clamp(1rem, 2.8vw, 1.75rem);
}

.eyebrow {
  margin-bottom: 0.24rem;
  color: #d8e6ff;
  text-transform: uppercase;
  letter-spacing: 0.16em;
  font-size: 0.72rem;
  font-weight: 800;
}

.muted {
  color: var(--muted);
  line-height: 1.5;
}

/* Standard, well-established screen-reader-only clip technique (arrangement-affordances) --
   visually hidden but still reachable by assistive tech and still announced, unlike `display:
   none`/`visibility: hidden`/`hidden`, all of which remove content from the accessibility tree
   entirely. Used by `#arrangement-announcer` and `#stream-arrangement-instructions` in room.html.
   Zero-dependency by design, matching this repo's "vanilla ES modules only" constraint -- there
   is no reason to reinvent this specific pattern. */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

.orb {
  width: 3.25rem;
  aspect-ratio: 1;
  border-radius: 50%;
  background:
    radial-gradient(circle at 35% 25%, white 0 8%, #b8f0ff 10% 18%, transparent 19%),
    radial-gradient(circle at 35% 33%, #d8b7ff, #8f62ff 42%, #239af0 68%, #132561 100%);
  box-shadow: 0 0 26px rgba(109, 194, 255, 0.8), inset 0 1px 10px rgba(255,255,255,0.7);
  flex: 0 0 auto;
}

.aero-panel {
  position: relative;
  border: 1px solid var(--glass-edge);
  border-radius: var(--radius-lg);
  background:
    linear-gradient(180deg, rgba(255,255,255,0.14), transparent 3rem),
    linear-gradient(135deg, rgba(37, 48, 98, 0.62), rgba(22, 18, 54, 0.46));
  box-shadow:
    0 14px 38px rgba(0,0,0,0.33),
    inset 0 1px rgba(255,255,255,0.24),
    inset 0 -1px rgba(0,0,0,0.24);
  backdrop-filter: blur(18px) saturate(145%);
  -webkit-backdrop-filter: blur(18px) saturate(145%);
}

.aero-panel::before {
  content: "";
  position: absolute;
  inset: 1px 1px auto 1px;
  height: 38%;
  border-radius: calc(var(--radius-lg) - 2px) calc(var(--radius-lg) - 2px) 45% 45%;
  background: linear-gradient(180deg, rgba(255,255,255,0.12), transparent);
  pointer-events: none;
}

.auth-panel,
.gate-panel,
.dashboard-card,
.flash-panel,
.aero-player-stage {
  padding: clamp(1rem, 2.5vw, 1.35rem);
}

.flash-panel {
  margin-bottom: 1rem;
  display: grid;
  gap: 0.7rem;
}

/* Room gate, second pane (the guest/room-password pane leads because it is
   overwhelmingly the more common arrival - see docs/comet/changes/room-gate-redesign/specs/
   room-entry-and-identity/spec.md, "The two paths must be told apart at a glance"). The old
   stacked-card spacing/opacity this pane's own rule used to carry (`margin-top`,
   `opacity: 0.92`) is gone: the panes now sit side by side at desktop (`.auth-window
   .gate-panel.gate-host-panel`'s tint, and `.gate-content`'s grid, both further down this
   file) and stack again at mobile with a distinct muted card look (the `@media
   (max-width: 900px)` block, also further down) - see those rules for the current
   treatment. */

.gate-hint {
  margin-top: -0.35rem;
  font-size: 0.85rem;
}

/* The room-password card's display name is optional - the server assigns
   one when it is left blank (see specs/room-entry-and-identity, "Names") -
   so it must not compete visually with the room password above it.
   login-gate-design-spec splits the label itself (full label color, like
   any other field) from the "(optional)" suffix (`.gate-optional`, dimmer
   and lighter-weight) rather than dimming the whole label as before -
   "Name" now reads as an ordinary field label; only the suffix marks it as
   skippable. */
.gate-name-label {
  font-weight: 600;
  color: var(--muted);
}

.gate-optional {
  font-weight: 400;
  color: var(--dim);
}

/* Refusals are shown inline above the form rather than on a separate error
   page, so the visitor can simply try again. login-gate-design-spec's
   "Error as inline feedback": a self-contained danger-colored banner (not
   the old left-border-only treatment) with a glowing dot indicator
   (`::before`) ahead of the message text - the dot is decorative, the
   `role="alert"` on the element itself (gate.html) is what actually
   announces the text to assistive tech. `.auth-window .gate-panel`'s glass
   reset above does not touch `.gate-error` (excluded there on purpose), so
   every property this rule needs is declared here in full rather than
   fighting that reset's specificity. */
.gate-error {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-bottom: 14px;
  padding: 10px 14px;
  border-radius: 10px;
  background: rgba(255,101,138,.12);
  border: 1px solid rgba(255,101,138,.5);
  box-shadow: 0 0 22px rgba(255,101,138,.16);
  color: #ffd6e0;
  font-size: 14px;
  line-height: 1.4;
}

.gate-error::before {
  content: "";
  flex: none;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--danger);
  box-shadow: 0 0 8px var(--danger);
}

/* Marks the room-password field itself as the one the error concerns, per
   login-gate-design-spec ("the field's border, label, and helper text all
   recolored to danger"): `.gate-error` is nested INSIDE `.gate-panel` now
   (gate.html, between the descriptor and the form - Artboards 1b/1d), not
   a preceding sibling of it, so `:has()` is what actually reaches "a panel
   that currently contains an error" - a sibling combinator cannot see
   inside its own subject. The name field is deliberately untouched: a
   wrong room password says nothing about whether the (unrelated, optional)
   name field was filled in correctly - see login-gate-design-spec's "Room
   password field" vs. "Name field" error-state notes, which flag only the
   former. */
.gate-panel:has(.gate-error) .gate-password-label {
  color: #ffd6e0;
}

.gate-panel:has(.gate-error) input[name="password"] {
  border-color: rgba(255,101,138,.7);
  box-shadow: inset 0 2px 6px rgba(0,0,0,.35), 0 0 0 3px rgba(255,101,138,.2);
}

.dashboard-grid {
  display: grid;
  gap: 1rem;
  align-items: stretch;
}

.admin-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }

/* The account overview gives assigned rooms the main column and keeps account
   tools beside them. Administration spans both columns with compact link cards. */
.account-dashboard-grid {
  grid-template-columns: minmax(0, 2fr) minmax(16rem, 1fr);
  align-items: start;
}
.dashboard-admin-grid {
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));
}
@media (max-width: 900px) {
  .account-dashboard-grid,
  .dashboard-admin-grid { grid-template-columns: minmax(0, 1fr); }
}

/* Explicit two-column template, not the auto-fit-driven single column this
   replaced. `auto-fit, minmax(min(100%, 34rem), 1fr)` only ever grows a
   second column when a second item is wide enough to need one - it never
   was: the lone room card lived in column 1 and the account card was
   `.full-width` (spanning both), so column 2 sat empty at every desktop
   width while content ran off the bottom of the screen (the reported
   defect this rule fixes; see the redesign brief and the comment on
   `.host-grid`'s two children in host.html). `minmax(18rem, 22rem)` keeps
   the sidebar column a legible, letter-width for the account form without
   letting it balloon on an ultrawide display - the same "no fixed pixel
   number is right for every screen" reasoning as `.aero-window`'s comment
   above, just bounded on both ends instead of deleted, since a sidebar
   (unlike the room window) has no reason to ever be wider than a form
   needs. `align-items: start` overrides the shared `.dashboard-grid`
   default of `stretch`: the account card is explicitly the secondary
   element on this page (see host.html's comment on `.host-sidebar`) and
   must not be stretched to match a taller rooms column, which would
   visually promote it back to equal weight with the broadcast setup. */
.host-grid { grid-template-columns: minmax(0, 1fr) minmax(18rem, 22rem); align-items: start; }

.full-width { grid-column: 1 / -1; margin-top: 1rem; }

.card-header {
  margin-bottom: 1rem;
}

.card-header.horizontal {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 1rem;
}

/* Zero, not just "tighter than the shared `.card-header`'s 1rem": adjoining
   sibling margins collapse to whichever is larger in normal flow, and
   `.connect-panel` (a `.nested-panel`) right below always contributes its
   own `margin-top` - see that rule. Leaving any positive margin here just
   gets discarded in favor of the nested-panel's, so it is set to 0 here
   and the nested-panel's `margin-top` is the one number that actually
   governs the gap, scoped to `.stream-config-card` only so the admin
   console's own cards (which also use `.card-header` and, unlike this
   card, are not followed by a `.nested-panel`) are untouched. */
.stream-config-card .card-header {
  margin-bottom: 0;
}

/* Two more scoped trims toward the same budget, neither touching the
   shared `h2`/`p`/`.muted` rules other pages rely on: the shared `h2` rule
   sets a 0.8rem bottom margin sized for a card that is the whole visual
   unit (e.g. admin's cards); here it sits above a slug line and a button
   that are still part of one compact identity block. The room slug's `<p
   class="muted">` was never given its own margin rule at all, so it was
   inheriting the plain UA default paragraph margin (1em, ~16px) - the
   single largest unaccounted-for gap found while measuring this card with
   scripts/measure-host-layout.mjs. */
.stream-config-card .card-header h2 {
  margin-bottom: 0.4rem;
}

.stream-config-card .card-header p.muted {
  margin: 0 0 0.5rem;
}

.aero-form {
  display: grid;
  gap: 0.8rem;
}

.aero-form.compact { align-content: start; }

label {
  display: grid;
  gap: 0.35rem;
  color: #dde8ff;
  font-weight: 650;
}

input {
  width: 100%;
  min-height: 2.75rem;
  font: inherit;
  color: var(--text);
  border-radius: var(--radius-md);
  border: 1px solid rgba(196, 214, 255, 0.28);
  background:
    linear-gradient(180deg, rgba(255,255,255,0.1), rgba(255,255,255,0.035)),
    rgba(5, 9, 24, 0.58);
  box-shadow: inset 0 1px 3px rgba(0,0,0,0.55), 0 1px rgba(255,255,255,0.08);
  padding: 0.72rem 0.78rem;
  outline: none;
}

input:focus {
  border-color: rgba(111, 210, 255, 0.78);
  box-shadow: 0 0 0 3px rgba(97, 194, 255, 0.16), inset 0 1px 3px rgba(0,0,0,0.55);
}

/* Every gated form in admin.html opens with a `<button type="submit"
   disabled hidden>` (the Enter-key implicit-submission guard - see
   admin.html's top-of-tabs comment). The browser's own user-agent
   stylesheet already sets `[hidden] { display: none }`, but CSS's cascade
   ORIGIN rules mean any author-stylesheet rule beats a user-agent rule
   regardless of specificity - so the plain `button { display: inline-flex;
   ... }` rule below, applying to literally every <button> in the app,
   silently overrode `hidden` and rendered these guard buttons as visible,
   empty pill-shaped buttons (found by looking at an actual screenshot of
   the confirm-gate flow - exactly the kind of defect a rendered screenshot
   catches and a passing `go test` cannot). This rule restores the native
   behavior specifically for hidden buttons, without touching how any
   visible button (`.aero-button` or plain `button`) looks. */
button[hidden] {
  display: none;
}

.aero-button,
button {
  appearance: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.4rem;
  min-height: 2.55rem;
  width: fit-content;
  max-width: 100%;
  font: inherit;
  font-weight: 800;
  color: white;
  cursor: pointer;
  border-radius: var(--radius-sm);
  border: 1px solid rgba(210, 228, 255, 0.42);
  padding: 0.65rem 1rem;
  background:
    linear-gradient(180deg, rgba(255,255,255,0.34), rgba(255,255,255,0.06) 48%, rgba(0,0,0,0.18) 49%),
    linear-gradient(135deg, #8256ff, #2477d8);
  box-shadow:
    inset 0 1px rgba(255,255,255,0.65),
    inset 0 -1px rgba(0,0,0,0.42),
    0 7px 18px rgba(31, 85, 212, 0.28),
    0 0 18px rgba(139, 99, 255, 0.22);
  text-shadow: 0 1px 1px rgba(0,0,0,0.6);
  /* Motion: buttons/pills app-wide (motion-symmetry pass, item 6). One shared rule, on the same
     selector every button variant across the app already inherits from (`.aero-button`/plain
     `button` - see this rule's own header comment above), rather than a per-button rule anywhere
     else in this pass: exactly the brief's own instruction. `--motion-fast`, not `--motion-base`
     - hover/press feedback needs to feel immediate, not like an entrance. Only the three
     properties the brief names (background-color/border-color/transform): this button's own
     hover `filter: brightness(1.08)` below is deliberately left unlisted, matching the brief's
     literal, prescriptive property set rather than broadening it unasked. */
  transition: background-color var(--motion-fast) var(--ease-out), border-color var(--motion-fast) var(--ease-out),
    transform var(--motion-fast) var(--ease-out);
}

.aero-button:hover,
button:hover {
  transform: translateY(-1px);
  text-decoration: none;
  filter: brightness(1.08);
}

/* Active press: `scale(0.97)` per the brief, composed with (not replacing) the existing
   `translateY(0)` un-lift below -- a button pressed while still hovered should look both
   "returned to rest" and "pressed", not just one or the other. */
.aero-button:active,
button:active {
  transform: translateY(0) scale(0.97);
  box-shadow: inset 0 2px 8px rgba(0,0,0,0.42), 0 3px 10px rgba(31,85,212,0.18);
}

@media (prefers-reduced-motion: reduce) {
  .aero-button,
  button {
    transition: none;
  }
}

.aero-button.primary { background: linear-gradient(180deg, rgba(255,255,255,0.36), rgba(255,255,255,0.06) 48%, rgba(0,0,0,0.18) 49%), linear-gradient(135deg, #a15cff, #208cff); }
.aero-button.ghost { background: linear-gradient(180deg, rgba(255,255,255,0.2), rgba(255,255,255,0.05)), rgba(12, 20, 48, 0.35); }
.aero-button.danger { background: linear-gradient(180deg, rgba(255,255,255,0.28), rgba(255,255,255,0.05) 48%, rgba(0,0,0,0.18) 49%), linear-gradient(135deg, #b12b75, #cf3b50); }
/* Every control that ends a session (room/host/admin "Log out", plus the
   room's "Leave room", which shares the slot) is red on every page - see
   docs/comet/changes/room-layout-redesign/specs/room-view-presentation/
   spec.md ("Controls that end a session are red"). Built from --danger/
   --danger-strong rather than a flat bootstrap red, matching the same
   two-stop diagonal gradient treatment as .primary/.danger above so it
   reads as part of the same button family. Kept separate from
   .aero-button.danger (irreversible admin actions like Delete user):
   ending a session is not destructive, and "Leave room" is navigation, red
   here only for positional consistency with the slot it occupies. */
.aero-button.signout { background: linear-gradient(180deg, rgba(255,255,255,0.28), rgba(255,255,255,0.05) 48%, rgba(0,0,0,0.18) 49%), linear-gradient(135deg, var(--danger), var(--danger-strong)); }
.aero-button.small { min-height: 2.2rem; padding: 0.45rem 0.75rem; font-size: 0.9rem; }

/* Groups the optional cross-console nav link (admin.html's "Host console",
   host.html's "Admin console") with the Log out form in the titlebar, so
   .aero-titlebar keeps exactly two flex children (the title block and this
   one) regardless of whether the nav link renders - see .aero-titlebar's
   `justify-content: space-between` above, which would otherwise spread a
   third bare child away from the logout button instead of keeping the two
   actions together. */
.titlebar-actions {
  display: flex;
  align-items: center;
  gap: 0.6rem;
}

.room-list,
.check-list {
  margin: 0;
  padding: 0;
}

.room-list {
  display: grid;
  gap: 0.75rem;
}

.room-row {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 1rem;
  padding: 0.8rem;
  border: 1px solid rgba(178, 204, 255, 0.18);
  border-radius: var(--radius-md);
  background: rgba(4, 8, 24, 0.26);
}

/* Replaces the old `.settings-list` (stacked dt/dd blocks, each its own
   bordered box with ~1.5rem of padding+border on top of its content) with
   one-line label/value rows separated by a hairline instead of a full box
   each - part of what makes room for the OBS checklist and credentials
   action in the same card without the page overflowing 900px tall. See
   host.html's "Connect OBS" panel and docs/comet/changes/
   host-dashboard-redesign/brief.md ("stop making hosts scroll"). */
.connection-list {
  display: grid;
  gap: 0.4rem;
  margin: 0 0 0.85rem;
}

.connection-row {
  display: grid;
  grid-template-columns: 8.5rem 1fr;
  align-items: start;
  gap: 0.6rem;
  padding-bottom: 0.3rem;
  border-bottom: 1px solid rgba(178, 204, 255, 0.14);
}

.connection-row:last-child {
  padding-bottom: 0;
  border-bottom: none;
}

.connection-row dt,
.connection-row dd {
  margin: 0;
  min-width: 0;
}

/* Holds a connection value, its copy button, and the button's status
   message as one flex row - deliberately `flex-wrap: nowrap` (the
   default; not stated as a safety-valve override the way most `wrap`
   uses in this file are), because the failure mode here runs the other
   direction from usual: with `wrap` allowed, a long value (the OBS URL is
   the long one - see host.html) whose own hypothetical unwrapped width
   exceeds the row claims the *entire* first flex line for itself under
   Chromium's flex sizing, pushing the button and status to a second line
   below it and roughly doubling the row's height - measured directly with
   scripts/measure-host-layout.mjs while chasing the "stop making hosts
   scroll" target (968px against a 900px budget at 1440x900, ~43px of it
   this one rule). `min-width: 0` on `code` below is what lets it actually
   shrink to the width the button leaves it, so its own `overflow-wrap:
   anywhere` (see the base `code` rule) can do the wrapping *inside* the
   value instead of the whole item wrapping *around* the button.
   `align-items: flex-start` (not `center`) keeps the button/status
   pinned to the top of a value that wraps across two or three lines,
   rather than vertically centered in the middle of the block. */
.copy-field {
  display: flex;
  align-items: flex-start;
  gap: 0.5rem;
}

.copy-field code {
  flex: 1 1 auto;
  min-width: 0;
}

/* Reserves a line's height even when empty so clicking "Copy" doesn't
   nudge the row (and everything below it) down by a line - the classic
   layout-shift-from-status-text problem. Color starts matching `--success`
   because the only two messages host.js ever writes here ("Copied" and
   "Selected - press Ctrl+C") are both positive outcomes; there is no error
   message this button can produce (see host.mjs's copyText - the fallback
   path is a degrade, not a failure state). */
.copy-status {
  min-height: 1.1em;
  font-size: 0.75rem;
  color: var(--success);
  /* Motion: copy-button "Copied" feedback (motion-symmetry pass, item 9). Pure CSS, no host.js
     changes needed: `:empty`/`:not(:empty)` already tracks exactly what host.js's setStatus
     callback controls (`statusEl.textContent = message` to show, `= ''` to clear -- an empty
     string leaves no child node at all, so `:empty` matches). `min-height` above already
     reserves this row's height unconditionally, so fading opacity alone never causes a layout
     shift either direction. Enter timing when text appears, exit timing when host.js's own
     2.5s timeout clears it back to empty (see host.js's wireCopyButtons).*/
  opacity: 1;
  transition: opacity var(--motion-base) var(--ease-out);
}

.copy-status:empty {
  opacity: 0;
  transition: opacity var(--motion-fast) var(--ease-in);
}

@media (prefers-reduced-motion: reduce) {
  .copy-status {
    transition: none;
  }
}

dt {
  color: var(--dim);
  font-size: 0.78rem;
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: 0.08em;
}

dd { margin: 0; }

.nested-panel {
  margin-top: 0.75rem;
  padding: 0.75rem 1rem;
  background: rgba(4, 8, 24, 0.3);
}

.check-list {
  list-style: none;
  display: grid;
  gap: 0.52rem;
}

.check-list li {
  position: relative;
  padding-left: 1.55rem;
  color: #dce7ff;
}

.check-list li::before {
  content: "✓";
  position: absolute;
  left: 0;
  top: 0;
  color: var(--success);
  text-shadow: 0 0 10px rgba(101, 240, 197, 0.72);
}

/* Both children of `.host-grid` (see that rule's comment, above, and
   host.html): one always holds every assigned-room card (or the empty
   state), the other always holds the account card. `min-width: 0` on both
   lets a long OBS URL's `code` element actually shrink/wrap inside its
   grid column instead of forcing the column wider than `.host-grid`
   allotted it - the ordinary "grid/flex items default to their content's
   intrinsic minimum width" trap. */
.host-rooms,
.host-sidebar {
  display: grid;
  gap: 1rem;
  min-width: 0;
}

/* Everything about getting OBS connected for one room, condensed into a
   single nested panel instead of the two ("OBS settings", "Credentials")
   the pre-redesign card used - two separate bordered/padded boxes cost
   more vertical space than the content inside them, which is the opposite
   of this change's goal. See host.html's "Connect OBS" panel. */
.connect-panel h3 {
  margin-bottom: 0.5rem;
}

/* Five short items cost noticeably less vertical space at a tighter row
   gap than the base `.check-list`'s 0.52rem - part of the same "stop
   making hosts scroll" budget as `.connection-list`/`.nested-panel`
   above. Scoped to `.obs-checklist`, not `.check-list` itself, for the
   same reason as the desktop 2-column override in the
   `@media (min-width: 901px)` block below: nothing else reuses this
   visual today, but the class is generic, and a rule written for this one
   card should not silently reflow anything else that starts using it. */
.obs-checklist {
  gap: 0.35rem;
}

/* A hairline-divided row, not a third bordered/padded nested-panel: the
   rotate-key action is common enough to want a visible separation from the
   OBS settings above it, but not so central to "get OBS connected" that it
   earns another full box's worth of padding - see the "Connect OBS" panel
   comment above for the same box-count reasoning. `flex-wrap` lets the
   hint text and the button share one row when there is room (desktop) and
   drop the button to its own line when there is not (a narrow stacked
   sidebar-width card), rather than a hard breakpoint that has to guess
   the card's width up front. */
.credentials-row {
  margin-top: 0.7rem;
  padding-top: 0.6rem;
  border-top: 1px solid rgba(178, 204, 255, 0.16);
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem;
}

.credentials-hint {
  margin: 0;
  flex: 1 1 16rem;
  font-size: 0.85rem;
}

.credentials-row form {
  flex: 0 0 auto;
}

.status-pill {
  display: inline-flex;
  align-items: center;
  gap: 0.45rem;
  width: fit-content;
  white-space: nowrap;
  border: 1px solid rgba(213, 230, 255, 0.32);
  border-radius: var(--radius-pill);
  padding: 0.38rem 0.68rem;
  color: #eef6ff;
  background: linear-gradient(180deg, rgba(255,255,255,0.18), rgba(255,255,255,0.05)), rgba(10, 20, 48, 0.48);
  box-shadow: inset 0 1px rgba(255,255,255,0.28), 0 0 18px rgba(98,199,255,0.14);
  font-size: 0.82rem;
  font-weight: 800;
}

/* Status pill indicator dot - first child only. Some status pills have a
   second span child containing text (e.g. #live-overview-generated on /admin,
   #health-status-* on /host). The :first-child selector ensures only the
   decorative dot span (empty, rendered as a colored circle via aspect-ratio)
   gets the 0.62rem width; text content in a sibling span renders at normal
   width, not clipped to the dot's 10px size. Without :first-child, text
   spans inherited the dot's width, causing horizontal document overflow. */
.status-pill > span:first-child {
  width: 0.62rem;
  aspect-ratio: 1;
  border-radius: 50%;
  background: var(--accent-2);
  box-shadow: 0 0 12px currentColor;
}

.status-pill.live > span:first-child,
.status-pill.ready > span:first-child { background: var(--success); }
.status-pill.live { color: #dffff6; }
.status-pill.ready { color: #e7fff8; }

/* Failed-outcome marker on the admin audit log (admin.html) - same dot/pill
   shape as .live/.ready above, colored from --danger instead of --success
   so a failed admin action reads as an alert at a glance in the list. */
.status-pill.failure > span:first-child { background: var(--danger); }
.status-pill.failure { color: #ffe1ea; }

/* Viewer presence count, top right of the room page, alongside the "Live
   grid" status pill. See
   docs/comet/changes/viewer-presence-count/specs/viewer-presence-count/spec.md. */
.room-titlebar-meta {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 0.5rem;
}

.viewer-count-pill {
  margin: 0;
  display: inline-flex;
  align-items: center;
  width: fit-content;
  white-space: nowrap;
  border: 1px solid rgba(213, 230, 255, 0.24);
  border-radius: var(--radius-pill);
  padding: 0.3rem 0.68rem;
  color: var(--muted);
  background: linear-gradient(180deg, rgba(255,255,255,0.12), rgba(255,255,255,0.03)), rgba(10, 20, 48, 0.34);
  font-size: 0.76rem;
  font-weight: 700;
  letter-spacing: 0.02em;
}

/* The video column is the largest 16:9 box that fits both axes at once -
   see specs/room-view-presentation/spec.md ("The video is the largest
   16:9 box that fits"). `flex: 0 0 auto` (shared by both the >900px and
   <=900px layouts - previously only true below the breakpoint, where it
   had to override a `1 1 auto` here) means this item never grows or
   shrinks via the flex algorithm; its own size is worked out from
   `aspect-ratio` plus the width/height bounds declared for each layout
   below, not from "whatever the row leaves." Above 900px those bounds are
   both a width (the space beside the chat column) and a height (see the
   `@media (min-width: 901px)` block below); at or below it, only a width
   applies and height still comes from that width via `aspect-ratio`,
   unchanged from before. Deliberately no `overflow: hidden` here: a
   streamer's small self-preview (room.js's ensureSelfPreviewElement
   inserts it as a sibling before #room-grid, inside this same element)
   sits above the main grid, and the two together can need more height
   than the 16:9 box alone provides; `aspect-ratio` is a preferred size,
   not a hard cap, so when a self-preview is also present the box grows to
   fit rather than clipping either one. `min-width: 0` lets it shrink below
   its content's intrinsic width instead of overflowing the row when the
   chat column's width leaves little room. */
/* `position: relative` makes this the containing block for `#arrangement-snap-overlay`'s
   `inset: 0` (arrangement-affordances) -- it had none before, and without it that overlay would
   position against the next actually-positioned ancestor up the tree instead of this stage. */
.aero-player-stage {
  position: relative;
  flex: 0 0 auto;
  min-width: 0;
  aspect-ratio: 16 / 9;
  /* Zeroes the shared `.auth-panel, .gate-panel, .dashboard-card,
     .flash-panel, .aero-player-stage { padding: ... }` rule above, for the
     room page's video stage specifically - not chrome *around* the video
     (`.room-columns`'s own padding, below), but the stage's own inset, so
     the video element's box reaches all the way to what `.room-columns`
     gives it. Used to be conditional (`.theme-theater .aero-player-stage`);
     unconditional now that this is the room page's only look. */
  padding: 0;
}

/* `height: 100%` fills the now-fixed-height video column above so a single
   tile (grid-single) matches it exactly, and multiple tiles subdivide it -
   see specs/room-view-presentation/spec.md ("Grid adaptivity"). This is the
   same "auto row stretches to a definite grid-container height" mechanic
   the previous flexbox-derived height relied on; only the source of that
   definite height changed. */
.stream-grid {
  display: grid;
  gap: 12px;
  align-items: stretch;
  height: 100%;
}

.grid-empty {
  place-items: center;
}

.grid-single { grid-template-columns: 1fr; }
.grid-two { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.grid-three,
.grid-four,
.grid-many { grid-template-columns: repeat(2, minmax(0, 1fr)); }
/* Three streams: the odd third tile spans both columns and centres itself at the same size as the
   first row's tiles (design pass 2, artboards 2a/2c) instead of hugging the left column. */
.grid-three .stream-tile:nth-child(3) {
  grid-column: 1 / -1;
  justify-self: center;
  width: calc(50% - 6px);
}
.grid-two .stream-tile,
.grid-three .stream-tile,
.grid-four .stream-tile,
.grid-many .stream-tile {
  border-radius: 14px;
  box-shadow: 0 0 0 1px rgba(193,213,255,.18), 0 24px 60px rgba(0,0,0,.5);
}

/* No `border` (room-layout-defects): a measurement pass found twelve distinct bordered shapes
   rendering at once on this page, and the worst offender was this rule's own 1px hairline facing
   `#chat-panel`'s 1px hairline across the `--room-gap` between them - two parallel full-height
   lines 12px apart, reading as a seam rather than a deliberate edge. Chosen over dropping chat's
   border instead: chat is a UI panel (a form, a list, controls) that benefits from a legible
   boundary, especially now that it is inset with real margins on its own row (see `.chat-panel`'s
   desktop rule); the video is content, and this app's whole "full-bleed, edge-to-edge" premise
   (docs/comet/changes/theater-as-default/brief.md) argues for it floating in the matte background
   rather than sitting in a bordered card next to another bordered card. `box-shadow` stays - a
   soft, blurred drop shadow reads as depth/lift, not a hard seam, so the tile still separates
   visually from the ground and from its neighbours in a multi-tile grid (where `gap: 1rem` on
   `.stream-grid` already does most of that work) without a hairline outlining it. */
.stream-tile {
  position: relative;
  /* Vertical inset of the tile's top chrome row (stats toggle, grip, LIVE pill, PiP, self-view).
     Tiles whose top edge sits under the floating 44px titlebar push it below the bar (desktop
     rule after the grid rules); every other tile keeps the plain inset. Design pass 1. */
  --tile-chrome-top: var(--inset-chrome);
  /* Fixed widths the right-hand chrome group is laid out from (design pass 2): PiP is right-most,
     LIVE sits 8px left of it, the streamer's self-view toggle 8px left of LIVE. */
  --pip-width: 2.9rem;
  --live-pill-width: 4.5rem;
  overflow: hidden;
  /* Design pass 2: a 16px single/focus tile with a 1px light ring and the room's violet glow; grid
     and rail tiles override radius/shadow below. */
  border-radius: 16px;
  background: #0a0d1a;
  box-shadow: 0 0 0 1px rgba(193,213,255,.18), 0 30px 80px rgba(0,0,0,.55), 0 0 120px rgba(160,108,255,.18);
}

/* Motion: room tile enter/exit (motion-symmetry pass, item 1). `.stream-tile-enter`/
   `-exit` are state-change classes room-grid.mjs applies only once `document.body` already
   carries `.motion-ready` (Part A) -- gated here again, redundantly, by the same
   `body.motion-ready` ancestor selector so a first paint before that class lands can never show
   this transition even if some future caller forgets that guard. Enter animates opacity+scale
   together (0 -> 1 / 0.98 -> 1, `--motion-base`/`--ease-out` -- Part A rule 2's "enter" timing);
   exit is opacity-only at the faster `--motion-fast`/`--ease-in` "exit" timing, deliberately
   without the scale, so a leaving tile does not look like it is shrinking into whatever the grid
   reflows underneath it. */
body.motion-ready .stream-tile {
  transition: opacity var(--motion-base) var(--ease-out), transform var(--motion-base) var(--ease-out);
}

body.motion-ready .stream-tile.stream-tile-enter {
  opacity: 0;
  transform: scale(0.98);
}

body.motion-ready .stream-tile.stream-tile-exit {
  opacity: 0;
  transition: opacity var(--motion-fast) var(--ease-in);
}

@media (prefers-reduced-motion: reduce) {
  /* Same selector (`body.motion-ready .stream-tile`), not the bare `.stream-tile`: the base
     transition rule above is scoped behind that same ancestor class, at specificity (0,2,1) --
     a bare `.stream-tile { transition: none; }` here is only (0,1,0) and loses to it regardless
     of source order, leaving the transition still active under reduced motion. Caught by
     scripts/measure-room-layout.mjs's item 14 motion-discipline gate, which samples exactly this
     failure mode. */
  body.motion-ready .stream-tile {
    transition: none;
  }
}

/* Motion: FLIP spatial move (motion-symmetry pass, item 2). room-flip.mjs (via room.js's
   renderGrid) applies this class alongside an inline `transform` that inverts a tile's
   just-changed position/size back to where it visually was, then removes both a paint later so
   this rule's own transition animates from that inverted state back to identity -- the tile's
   real new arrangement position. Spatial-move timing per Part A rule 2 ("spatial moves =
   --motion-slow var(--ease-out)"), not the enter/exit timing above -- a tile sliding from the
   rail into the focus slot is a move, not an arrival or a departure. `transform-origin: 0 0`
   matches room-flip.mjs's own left/top-anchored rect math (translate is computed against each
   box's top-left corner, not its center, so the default `50% 50%` origin would scale around the
   wrong point). Gated behind `body.motion-ready` for the same first-paint reason every other rule
   in this pass is, even though FLIP is self-guarding in practice (room-flip.mjs's
   computeFlipDelta returns null, and playFlip applies nothing, for a tile with no "before" rect --
   which is every tile on the very first render, before anything has moved). */
body.motion-ready .flip-animating {
  transition: transform var(--motion-slow) var(--ease-out);
  transform-origin: 0 0;
}

@media (prefers-reduced-motion: reduce) {
  /* Same specificity-matching fix as `.stream-tile`'s own reduced-motion override above -- see
     its comment. */
  body.motion-ready .flip-animating {
    transition: none;
  }
}

.stream-tile video {
  width: 100%;
  height: 100%;
  aspect-ratio: 16 / 9;
  background: #000;
  display: block;
  /* `contain` (never the initial `fill`, which stretches/distorts source
     content to fit its box) is what actually delivers "letterbox, preserves
     the whole frame" for a stream whose own aspect ratio does not exactly
     match this box's 16/9. `.theater-fill`, added to <body> by room.js's
     applyFitMode (see room-theater.mjs), switches this to `cover` instead
     - see that rule below - never to `fill`, so neither mode this page
     offers can ever distort the source. */
  object-fit: contain;
}

/* Host label chip: overlaid on the video at the top-left of the tile, a
   titlebar's-height-plus-inset below the tile's top edge. Absolute
   positioning keeps it out of layout flow so it never changes tile/grid
   dimensions. The translucent dark background plus text-shadow keep the
   username legible over bright video content. */
/* `top` derives from `--room-titlebar-height` (room-layout-defects) rather than an independent
   `2.5rem`: measurement found the two landed 3px apart by coincidence (44px titlebar bottom vs a
   hand-picked 40px), close enough to read as a misaligned line but not tied together by anything
   that would keep them that way. `+ 0.75rem` reuses the exact gap `.chat-panel`'s own desktop
   `top` inset already uses to clear this same titlebar (see that rule's comment) - one idiom for
   "sits just below the floating titlebar," not two independently-tuned numbers. This label used to
   sit just below a top-left "Live" badge (`.stream-tile::before`, `top: 0.75rem`); that badge has
   since been removed outright (tile-chrome-fix item 3 -- it was redundant with the room's own
   "Live grid" status pill, and its `top: 0.75rem`/`left: 0.75rem` slot now belongs to
   `.stream-tile-stats-toggle` instead, see that rule's own comment), but this label's own position
   was never tied to the badge's presence (only to the shared titlebar-height constant), so it did
   not need to move when the badge went away. */
.stream-tile-label {
  position: absolute;
  z-index: 2;
  top: var(--tile-chrome-top);
  left: var(--inset-chrome);
  height: 28px;
  display: inline-flex;
  align-items: center;
  max-width: calc(50% - 5.5rem);
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
  border: 1px solid var(--edge);
  border-radius: var(--radius-pill);
  padding: 0 12px 0 10px;
  gap: 6px;
  color: var(--text);
  background: var(--ink);
  box-shadow: inset 0 1px 0 rgba(255,255,255,.18);
  font-size: 13px;
  font-weight: 600;
  line-height: 1;
  backdrop-filter: var(--blur-chrome);
  pointer-events: none;
}

/* "Go live" per-tile control (room-playback-rewind): shown only while that tile's viewer has
   deliberately seeked behind the live edge -- see room.js's defaultAttachHLS and
   docs/comet/changes/room-playback-rewind/specs/room-playback-latency/spec.md ("Moving back
   through the stream"). Absolutely positioned within `.stream-tile` (already `position:
   relative`), exactly like the host label above, so it never participates in tile/grid layout and
   can never be the thing that grows `.aero-player-stage` past its own aspect-ratio box -- see
   docs/comet/changes/room-layout-redesign/specs/room-view-presentation/spec.md ("Height comes from
   content, never from the viewport"). Top-right: clear of the host label and stats toggle (both
   top-left, see `.stream-tile-stats-toggle`'s own comment for why it moved there) and clear of the
   native video controls bar the browser overlays at the bottom. This is the rightmost, anchor
   member of the top-right row (tile-chrome-fix item 1): `.stream-tile-pip-toggle` and
   `.live-edge-pill` both derive their own `right` from this button's own inset and a reserved
   width for it -- see `.live-edge-pill`'s own comment for the full row layout. */
.go-live-button {
  position: absolute;
  z-index: 2;
  top: var(--inset-chrome);
  right: var(--inset-chrome);
  /* The base `button` rule (above) imposes `min-height: 2.55rem` for ordinary full-size
     buttons; unset here so this sizes to its own small padding + font instead, matching the
     host label chip it sits alongside rather than towering over it. */
  min-height: unset;
  border: 1px solid rgba(255,255,255,0.32);
  border-radius: var(--radius-pill);
  padding: 0.28rem 0.65rem;
  color: #f6ecff;
  background: rgba(70, 27, 110, 0.82);
  box-shadow: 0 0 18px rgba(207, 121, 255, 0.32), inset 0 1px rgba(255,255,255,0.22);
  font-size: 0.78rem;
  font-weight: 850;
  backdrop-filter: blur(12px);
  cursor: pointer;
}

.go-live-button:hover,
.go-live-button:focus-visible {
  background: rgba(70, 27, 110, 0.95);
}

/* An author rule that sets `display` (none of the above do, but `[hidden]` itself is only a
   low-specificity UA-stylesheet rule) can be overridden by any author rule of any specificity
   from a higher-priority origin -- so a future `display` added to `.go-live-button` above would
   silently defeat `hidden` and show the control at all times. This restores the hide
   unconditionally regardless of what else this selector ever gains. */
.go-live-button[hidden] {
  display: none;
}

/* "Stream interrupted" panel (player-error-recovery): shown only once attachHLS's own
   escalating recovery (room.js) is exhausted for that tile's player -- silent recovery
   attempts before this point show nothing at all, per the "Silent auto-recovery, message
   only on genuine failure" decision in docs/comet/changes/player-error-recovery/specs/
   room-playback-recovery/spec.md. `inset: 0` covers the whole tile the same way
   `.self-preview-play` already does, absolutely positioned within the already-`position:
   relative` `.stream-tile`, so it can never grow `.aero-player-stage` past its own
   aspect-ratio box -- same reasoning as `.go-live-button` above. z-index above the "Live"
   badge, host label, and Go live button (all z-index: 2) so it fully covers them while shown. */
.stream-tile-interrupted {
  position: absolute;
  inset: 0;
  z-index: 3;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.75rem;
  padding: 1rem;
  text-align: center;
  background: rgba(6, 10, 26, 0.82);
  backdrop-filter: blur(6px);
}

/* Same reasoning as `.go-live-button[hidden]` above: restores the hide unconditionally
   regardless of what else this selector ever gains. */
.stream-tile-interrupted[hidden] {
  display: none;
}

.stream-tile-interrupted-message {
  margin: 0;
  color: #f6ecff;
  text-shadow: 0 1px 3px var(--shadow);
  font-size: 0.95rem;
  font-weight: 700;
}

.stream-tile-retry {
  min-height: unset;
  border: 1px solid rgba(255,255,255,0.32);
  border-radius: var(--radius-pill);
  padding: 0.4rem 1.1rem;
  color: #f6ecff;
  background: rgba(70, 27, 110, 0.82);
  box-shadow: 0 0 18px rgba(207, 121, 255, 0.32), inset 0 1px rgba(255,255,255,0.22);
  font-size: 0.85rem;
  font-weight: 850;
  backdrop-filter: blur(12px);
  cursor: pointer;
}

.stream-tile-retry:hover,
.stream-tile-retry:focus-visible {
  background: rgba(70, 27, 110, 0.95);
}

/* --- Stream arrangement (multi-stream-grid-configuration) ---
   See docs/comet/changes/multi-stream-grid-configuration/brief.md and
   docs/comet/changes/multi-stream-grid-configuration/specs/stream-arrangement/spec.md.

   `.stream-grid.arranged-focus-rail` only ever replaces `.grid-two`/`.grid-four`/`.grid-many`
   once a viewer has actually promoted a stream -- room-grid.mjs's renderArrangedStreamTiles only
   applies it when there is a live focus stream *and* something to put beside it (see its own
   comments). A viewer who never touches the layout gets exactly the rules above, unchanged,
   which is the whole of what keeps the default byte-identical to before this capability existed.

   Two flex children of `.stream-grid` -- `.arranged-focus` (exactly one tile) and `.arranged-
   rail` (the rest, scrollable) -- rather than a second CSS grid: a fixed split between two
   regions, one of which grows and one of which does not, is what plain flexbox already gives for
   free, and it is what keeps the split itself non-negotiable. There is deliberately no
   draggable divider here (see "The focus/rail split is a fixed proportion" in the spec) --
   `.arranged-rail`'s own fixed size below, not a percentage of the stage, *is* "fixed
   proportion": the focus region is simply whatever space is left once the rail takes its own
   constant width (or height, stacked -- see the narrow-viewport override further down), leaving
   nothing for a viewer to grab and drag. */
.stream-grid.arranged-focus-rail {
  display: flex;
  flex-direction: row;
  gap: 0.85rem;
  height: 100%;
}

.arranged-focus {
  display: flex;
  flex: 1 1 auto;
  min-width: 0;
  min-height: 0;
}

.arranged-focus .stream-tile {
  flex: 1 1 auto;
  min-width: 0;
  min-height: 0;
}

/* A subtle Aero-glass highlight rather than a different shape: the focus tile is still an
   ordinary `.stream-tile` (same border/label/overlay rules above), just the one the viewer
   chose to make large, so it gets the same kind of glow `.go-live-button` already uses rather
   than a visually unrelated new idiom. */
.stream-tile.is-focus {
  border-color: rgba(130, 200, 255, 0.55);
  box-shadow: 0 18px 42px rgba(0,0,0,0.46), 0 0 0 1px rgba(130, 200, 255, 0.32) inset, inset 0 1px rgba(255,255,255,0.28);
}

/* The rail: a fixed-width column of small tiles beside the focus on a wide screen (see the
   `@media (max-width: 900px)` override below for the narrow-screen "beneath it" version). Its
   own scrollbar, not the page's, is what "the rail scrolls" (spec, design target five streams)
   means in practice: beyond however many tiles fit this column's full height, the rest scroll
   into view without moving the focus tile or growing `.aero-player-stage` past its own bounds --
   `overflow-y: auto` here, nowhere else. */
.arranged-rail {
  display: flex;
  flex-direction: column;
  flex: 0 0 224px;
  gap: 12px;
  /* Thumbnails start below the floating titlebar, like the focus tile's own chrome row. */
  padding-top: calc(var(--room-titlebar-height) + 12px);
  overflow-y: auto;
  overflow-x: hidden;
  min-height: 0;
  padding-right: 0.15rem;
}

.arranged-rail .stream-tile {
  flex: 0 0 auto;
  aspect-ratio: 16 / 9;
  border-radius: 12px;
  box-shadow: 0 0 0 1px rgba(193,213,255,.18), 0 12px 30px rgba(0,0,0,.45);
}

/* Rail tiles are the same `.stream-tile` as everywhere else, just smaller -- the host label, Go
   live button, and interrupted panel all still overlay them at their normal fixed size otherwise,
   which crowds a tile this small. Scaled down here rather than hidden, so the same information
   (who this is, whether it needs attention) stays legible in the rail instead of disappearing from
   it. (This used to also cover the top-left "Live" badge, `.stream-tile::before` -- removed
   outright in tile-chrome-fix item 3, so there is nothing left for that badge's own selector to
   scale here.) */
.arranged-rail .stream-tile-label {
  top: 8px;
  left: 8px;
  height: 24px;
  padding: 0 10px;
  font-size: 12px;
  max-width: calc(100% - 4rem);
}

.arranged-rail .stream-tile-label {
  top: 1.55rem;
}

.arranged-rail .go-live-button,
.arranged-rail .stream-tile-retry {
  font-size: 0.68rem;
  padding: 0.2rem 0.5rem;
}

/* "Reset layout" (spec: "A viewer can discard their arrangement and return to the default at any
   time"). Only ever inserted into the DOM once an arrangement actually exists (see room.js's
   ensureResetButton) -- a viewer who has never touched the layout is never shown a control for
   undoing something they have not done. A small Aero-glass pill, the same treatment as
   `.viewer-count-pill`, rather than a full primary action button -- this is a quiet way out, not
   the room's main call to action. */
.arrangement-reset-button {
  min-height: unset;
  margin-top: 0.6rem;
  border: 1px solid rgba(213, 230, 255, 0.34);
  border-radius: var(--radius-pill);
  padding: 0.35rem 0.85rem;
  color: var(--text);
  background: linear-gradient(180deg, rgba(255,255,255,0.14), rgba(255,255,255,0.03)), rgba(10, 20, 48, 0.4);
  box-shadow: inset 0 1px rgba(255,255,255,0.22);
  font-size: 0.8rem;
  font-weight: 700;
}

.arrangement-reset-button:hover,
.arrangement-reset-button:focus-visible {
  background: linear-gradient(180deg, rgba(255,255,255,0.2), rgba(255,255,255,0.05)), rgba(10, 20, 48, 0.5);
}

/* Always-on grip affordance (arrangement-affordances): the visual "this is draggable" cue and
   the sole keyboard/AT entry point for rearranging a tile -- see room-grid.mjs's wirePromote/
   getGrip. Unlike `.go-live-button`/`.stream-tile-retry` above, which only ever appear in
   reaction to something going wrong, this must be visible *before* anything happens
   (discoverability, not feedback), and it must work without hover: touch devices have no hover
   state to reveal a hover-only affordance. Top-center is the one slot of the tile's top edge
   guaranteed clear of everything else already anchored here -- the host label and stats toggle
   both sit top-left, the (usually-hidden) "Go live" button top-right (see their own rules above,
   and `.stream-tile-stats-toggle`'s own comment for why it joined the top-left corner) -- there is
   nowhere else to place a third fixed overlay without colliding with one of them. */
/* Idle opacity (0.6) is well above zero, deliberately -- a hover-only reveal (opacity near 0
   until `:hover`) would be permanently invisible on any touch device, which has no hover state
   at all. Raised to fully opaque on hover/focus, or when any part of the tile is hovered (not
   only the grip itself, see the `:hover`/`:focus-visible` rule below), so moving the pointer
   anywhere over the tile confirms the grip is the thing to grab. */
.stream-tile-grip {
  position: absolute;
  z-index: 2;
  top: calc(var(--tile-chrome-top) + 6px);
  left: 50%;
  transform: translateX(-50%);
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: unset;
  width: 44px;
  height: 16px;
  padding: 0;
  border: 1px solid rgba(193,213,255,.22);
  border-radius: var(--radius-pill);
  color: var(--muted);
  background: var(--ink-soft);
  box-shadow: none;
  font-size: 0.6rem;
  line-height: 1;
  backdrop-filter: var(--blur-chrome);
  cursor: grab;
  opacity: 0.55;
}

.stream-tile-grip:hover,
.stream-tile-grip:focus-visible,
.stream-tile:hover .stream-tile-grip,
.arranged-focus .stream-tile .stream-tile-grip {
  opacity: 1;
  color: var(--text);
  background: var(--ink);
  border-color: rgba(193,213,255,.35);
}

/* Same focus-ring convention as `.chat-emoji-trigger:focus-visible`/`.aero-tab:focus-visible`
   elsewhere in this file, not a bespoke treatment for this one control. */
.stream-tile-grip:focus-visible {
  outline: 2px solid var(--accent-2);
  outline-offset: 2px;
}

/* Same reasoning as `.go-live-button[hidden]`/`.stream-tile-interrupted[hidden]` above: restores
   the hide unconditionally regardless of what else this selector ever gains. */
.stream-tile-grip[hidden] {
  display: none;
}

/* The rail's tiles are already scaled down (see `.arranged-rail .stream-tile-label` etc. above);
   the grip shrinks to match so it does not dominate a thumbnail-sized tile. */
.arranged-rail .stream-tile-grip {
  top: 8px;
  width: 36px;
  height: 14px;
  font-size: 0.5rem;
  opacity: 0.6;
}

/* Rail tiles hide heavy chrome (tile-chrome-round2 item 1): unlike the label/grip above, which
   are scaled DOWN because they still carry information worth keeping legible at thumbnail size
   ("who is this", "you can rearrange this"), the video-stats overlay and the playback-controls
   bundle (live-edge pill, scrubber, PiP) are all either diagnostic tools or fine-grained playback
   controls that make no sense on a ~13rem/6.5rem-wide thumbnail a viewer is not actively watching
   -- there is no room to render the stats panel legibly, and "seek this thumbnail" or "pop this
   thumbnail into PiP" are not real use cases the rail's own small scale supports. Removed outright
   (`display: none`), not merely scaled, the same way `clearPromoteWiring` removes rather than
   shrinks the grip's own affordance when a tile stops being promotable. Only the FOCUS tile and an
   ordinary (unarranged) grid tile keep this chrome -- see room-grid.mjs's own comment on
   renderArrangedStreamTiles for why an open stats overlay is also actively closed, not merely
   hidden, the moment a tile becomes a rail tile (closing the interval it owns, not just painting
   over it).

   Two classes' worth of specificity here (`.arranged-rail .X`) unconditionally beats every one of
   these controls' own single-class base rule regardless of source order or of that control's own
   `[hidden]` state (an attribute selector counts the same as a class for specificity purposes, so
   `.stream-tile-stats-panel[hidden]`'s existing `display: none` and this rule agree rather than
   race) -- see app.css's own comment on `button[hidden]` (~line 491) for why an author rule can
   otherwise silently outrank the UA `[hidden]` rule, and why this file already treats "beat it on
   specificity, not by hoping for the right source order" as the standing convention for exactly
   this class of bug. */
.arranged-rail .stream-tile-stats-toggle,
.arranged-rail .stream-tile-stats-panel,
.arranged-rail .stream-tile-pip-toggle,
.arranged-rail .live-edge-pill,
.arranged-rail .playback-scrubber-container {
  display: none;
}

/* --- Video stats debug overlay (video-stats-overlay) ---
   Modelled on Twitch's "Video Stats"/YouTube's "Stats for nerds": a small, always-visible toggle
   per tile (`.stream-tile-stats-toggle`), and a per-tile panel it opens/closes independently of
   every other tile's (`.stream-tile-stats-panel`). Both are absolutely positioned within
   `.stream-tile` (already `position: relative`), the same reasoning as the host label/grip/
   go-live-button above -- neither can ever grow `.aero-player-stage` past its own aspect-ratio
   box. No `transition` on either, open or closed, deliberately -- the brief's own words are "no
   animated transitions needed for this feature anyway", so there is nothing here for
   `prefers-reduced-motion` to need to override. */

/* Top-left (tile-chrome-fix item 2), not its original bottom-right: a bottom-right pill sat
   directly over wherever a browser's native `<video controls>` bar paints its own fullscreen/
   volume controls on hover, overlapping and sometimes eating clicks meant for this toggle --
   found and reported as a real defect, not merely theoretical, since a viewer trying to open
   stats while native controls happened to be showing could hit the wrong target underneath them.
   Top-left is free to use for this now: the top-left "Live" badge (`.stream-tile::before`) that
   used to occupy `top: var(--inset-chrome); left: var(--inset-chrome)` was removed outright in
   the same change (tile-chrome-fix item 3) as redundant with the room's own "Live grid" status
   pill, so this toggle simply takes over that exact slot -- same `--inset-chrome` insets every
   other corner pill on this tile uses, just the corner that opened up. Confirmed clear of every
   other tile chrome element: the grip sits top-center (`left: 50%`), the host label sits top-left
   but ~`--room-titlebar-height` lower (`.stream-tile-label`'s own `top`), and `.go-live-button`/
   `.live-edge-pill`/`.stream-tile-pip-toggle` all sit top-right as one row (tile-chrome-fix item
   1 -- see `.live-edge-pill`'s own comment), sharing this toggle's `top: var(--inset-chrome)` but
   anchored from `right` instead of `left`, so still a different corner and never the same rect.
   The toggle itself is small, permanently-visible chrome -- like the grip, it IS hit-tested by
   scripts/measure-room-layout.mjs's occlusion grid even while the panel is closed -- so it is
   sized close to the grip's own footprint (a "grip-sized ~2rem square-ish pill", per the original
   brief) and stays within scripts/measure-room-layout.mjs's OCCLUSION_BUDGET the same way it did
   at its old position: the "Live" badge it replaces in this same top-left corner used to spend
   roughly the same amount of that budget, so moving this toggle into the badge's old slot is a
   near wash for total top-edge occlusion, not a new addition to it -- see that harness's own
   OCCLUSION_BUDGET comment for the full budget accounting and current measured numbers. The panel
   itself is exempt from that same budget by design (opening it is a deliberate viewer action, and
   the harness never opens it during that specific assertion) -- see scripts/measure-room-
   layout.mjs's own stats-overlay probe. Note that `.stream-tile-stats-panel` below deliberately
   did NOT move with the toggle -- see that rule's own comment for why.

   `top: var(--inset-chrome)` below is desktop-correct only: on desktop `.stream-tile` sits well
   below `#room-titlebar` (an overlay only there, not an in-flow sibling), so any position within
   the tile automatically clears it. On a narrow phone the titlebar floats directly over the tile
   instead, and this toggle needs its own `@media (max-width: 900px)` override to stay clickable --
   see that rule's own comment (right after `.stream-tile-stats-panel`'s mobile override below) for
   the real click-interception defect that override fixes.

   z-index 4, not the 2 every other steady-state chip above uses -- found empirically by running
   scripts/measure-room-layout.mjs's own stats-overlay probe against this page: `.stream-tile-
   interrupted` (z-index 3) covers the whole tile, `inset: 0`, once a tile's recovery is
   exhausted, and at z-index 2 that overlay blocked every click on this toggle once it appeared --
   exactly the moment a viewer would most want to open stats to see why. Matches
   `.stream-tile-stats-panel`'s own z-index below for the same reason. This z-index reasoning is
   unaffected by the top-left move above -- `.stream-tile-interrupted` still covers the whole tile
   regardless of which corner this toggle sits in. */
.stream-tile-stats-toggle {
  position: absolute;
  z-index: 4;
  top: var(--tile-chrome-top);
  /* Sits 8px right of the name label; room-grid.mjs keeps --tile-label-width current via a ResizeObserver, 6rem is the no-observer fallback. */
  left: calc(var(--inset-chrome) + var(--tile-label-width, 6rem) + 8px);
  height: 28px;
  min-height: unset;
  display: inline-flex;
  align-items: center;
  border: 1px solid rgba(193,213,255,.22);
  border-radius: var(--radius-pill);
  padding: 0 11px;
  color: var(--muted);
  background: var(--ink-soft);
  box-shadow: inset 0 1px 0 rgba(255,255,255,.14);
  font-size: 12px;
  font-weight: 600;
  line-height: 1;
  backdrop-filter: var(--blur-chrome);
  cursor: pointer;
}

.stream-tile-stats-toggle:hover,
.stream-tile-stats-toggle:focus-visible,
.stream-tile:hover .stream-tile-stats-toggle {
  opacity: 1;
}

/* Same focus-ring convention as `.stream-tile-grip:focus-visible` above, not a bespoke treatment
   for this one control. */
.stream-tile-stats-toggle[aria-expanded="true"] {
  background: var(--btn-lit), rgba(42,54,112,.85);
  border-color: rgba(232,239,255,.5);
  box-shadow: inset 0 1px 0 rgba(255,255,255,.4);
  color: var(--text);
}

.stream-tile-stats-toggle:focus-visible {
  outline: 2px solid var(--accent-2);
  outline-offset: 2px;
}

/* Same reasoning as `.go-live-button[hidden]`/`.stream-tile-grip[hidden]` above: an author rule
   that sets `display` can silently defeat the low-specificity UA `[hidden] { display: none }`
   rule; this restores the hide unconditionally regardless of what else this selector ever gains.
   The panel starts hidden (room-stats.mjs's createStatsPanel) and is toggled by JS, never CSS, so
   this is the one rule standing between "closed" and "visible over every tile at once" if that
   author-origin cascade trap (see app.css's own comment near `button[hidden]`, ~line 491, for the
   exact mechanism) is ever reintroduced here. */
.stream-tile-stats-panel[hidden] {
  display: none;
}

/* The panel itself: an absolutely-positioned overlay, bottom-right, never an in-flow element --
   opening it must never push the tile or page layout around (spec). It used to be anchored just
   above its own toggle when that toggle also sat bottom-right; tile-chrome-fix item 2 moved the
   toggle to the tile's top-left corner (clear of the native video controls bar the old
   bottom-right placement collided with -- see the toggle's own comment above) but deliberately did
   NOT move this panel with it, out of that item's own scope, so the two are now positionally
   independent: click the top-left toggle, the panel still opens bottom-right. `max-height` +
   `overflow-y: auto` is what keeps ten rows of diagnostics from overflowing a small tile at
   390x844 rather than growing past it; the fixed rem cap here is deliberately roomy for a large
   desktop tile, with the `@media (max-width: 900px)` override below tightening it for a small
   mobile one -- the same "same concepts, tuned per device" pattern every other per-tile control on
   this page already follows (see e.g. `.arranged-rail .stream-tile-grip` above). Deliberately does
   NOT cover the grip's top-center slot or the native video control bar at the very bottom. z-index
   4, above `.stream-tile-interrupted` (z-index 3): the diagnostics this panel shows (stall count,
   time since the last stall) are exactly as useful -- arguably more so -- while a stream is in
   that recovery-exhausted state, so the panel must stay legible over it rather than being buried
   beneath it.

   `max-height` is a plain rem value, deliberately NOT a percentage of `.stream-tile` (this
   panel's containing block for position: absolute purposes) -- `.stream-tile` itself has no
   explicit height (it is sized by its `<video>` child's aspect-ratio, i.e. auto), and a
   percentage max-height against an auto-height containing block does not reliably constrain
   anything (confirmed empirically: an earlier `min(70%, 21rem)` here let the panel grow taller
   than a short mobile tile and spill out above the room titlebar, found by actually running
   scripts/measure-room-layout.mjs's stats-overlay probe and looking at its screenshot, not by
   reasoning about the CSS alone). */
.stream-tile-stats-panel {
  position: absolute;
  z-index: 4;
  right: var(--inset-chrome);
  bottom: 74px;
  width: min(312px, calc(100% - 24px));
  max-height: 21rem;
  overflow-y: auto;
  overflow-x: hidden;
  display: flex;
  flex-direction: column;
  gap: 0;
  padding: 0;
  border: 1px solid var(--edge);
  border-radius: var(--radius-card);
  color: var(--muted);
  background: rgba(5,7,17,.72);
  box-shadow: inset 0 1px 0 rgba(255,255,255,.18), 0 12px 32px rgba(0,0,0,.5);
  backdrop-filter: blur(14px);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 11.5px;
  line-height: 1.5;
}

/* Stats overlay drag (tile-chrome-round2 item 2): the panel's header (room-stats.mjs's
   createStatsPanel) is a pointer-draggable grab handle -- see room-stats-drag.mjs's own header
   comment for the clamp math -- so a viewer can move the panel off whatever part of the picture
   it happens to be covering, without leaving the tile it belongs to. Moved via
   `transform: translate`, on the motion-discipline allowlist (scripts/measure-room-layout.mjs's
   own gate permits animating transform/opacity/color and fails any other layout property) even
   though this control never actually transitions at all -- drag tracks the pointer 1:1, no
   easing, so there is nothing here for that gate to flag either way; the allowlist match is
   incidental, not the reason this uses `transform`. `cursor: grab`/`grabbing` mirrors
   `.stream-tile-grip`'s own affordance above. `touch-action: none` stops a touch-drag on the
   header from also trying to scroll the page underneath it. Deliberately its own row, not merged
   into `.stream-tile-stats-actions` below -- that row is a set of real actions (Copy stats); this
   one is a drag surface plus a label, and conflating the two would make the copy button itself
   part of the draggable area. */
.stream-tile-stats-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem;
  margin: 0;
  padding: 8px 12px;
  border-radius: 0;
  border-bottom: 1px solid rgba(193,213,255,.18);
  background: linear-gradient(180deg, rgba(255,255,255,.12), rgba(255,255,255,0));
  font-size: 11.5px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.12em;
  color: var(--text);
  cursor: grab;
  touch-action: none;
}

/* Three-dot drag glyph on the header's right (design pass 2). */
.stream-tile-stats-header::after {
  content: '';
  flex: none;
  width: 15px;
  height: 3px;
  background: radial-gradient(circle, var(--dim) 1.5px, transparent 1.7px) 0 0 / 6px 3px repeat-x;
}

.stream-tile-stats-header:active {
  cursor: grabbing;
}

/* Set on `<body>` for the duration of a drag gesture (room-stats-drag.mjs), not on the panel
   itself: a fast pointer move can outrun the panel and briefly cross other elements before the
   next pointermove fires, and text selection starting on any of THEM mid-drag is just as
   disruptive as selection starting on the panel's own text. Reset unconditionally on
   pointerup/pointercancel, so a drag that ends outside the header (pointer capture keeps
   delivering events to it regardless) still cleans up correctly. */
body.stats-panel-dragging {
  user-select: none;
}

.stream-tile-stats-row {
  display: grid;
  grid-template-columns: auto 1fr;
  column-gap: 14px;
  row-gap: 0;
  align-items: baseline;
  padding: 1px 12px;
}

/* The header is child 1 and the actions row the last child, so rows 2..n-1 carry the block padding. */
.stream-tile-stats-row:nth-child(2) {
  padding-top: 8px;
}

.stream-tile-stats-row:nth-last-child(2) {
  padding-bottom: 8px;
}

.stream-tile-stats-label {
  flex: 0 0 auto;
  color: var(--dim);
  font-size: inherit;
  font-weight: 400;
  text-transform: none;
  letter-spacing: 0;
}

/* Monospace + tabular-nums value cells. This codebase has no `--mono` CSS variable -- the
   existing convention (see `.confirm-typed input`, ~line 2359) is a literal repeated font stack,
   copied verbatim here rather than introducing a new variable this one panel would be the sole
   user of. `font-variant-numeric: tabular-nums` keeps digit columns from visibly jittering side to
   side on every ~1s tick (bandwidth, buffer health, etc. all change digit count over time). */
.stream-tile-stats-value {
  font-family: inherit;
  font-variant-numeric: tabular-nums;
  font-size: inherit;
  color: var(--text);
  text-align: right;
  word-break: break-word;
}

.stream-tile-stats-actions {
  display: flex;
  align-items: center;
  gap: 8px;
  margin: 0;
  padding: 0 12px 10px;
  border-top: 0;
}

.stream-tile-stats-copy {
  height: 28px;
  min-height: unset;
  display: inline-flex;
  align-items: center;
  padding: 0 12px;
  border: 1px solid var(--edge-lit);
  border-radius: var(--radius-pill);
  color: var(--text);
  background: var(--btn-lit), rgba(42,54,112,.85);
  box-shadow: inset 0 1px 0 rgba(255,255,255,.4);
  font-family: "Segoe UI", "Segoe UI Variable", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
  font-size: 12px;
  font-weight: 600;
  line-height: 1;
  cursor: pointer;
}

.stream-tile-stats-copy:hover,
.stream-tile-stats-copy:focus-visible {
  background: rgba(70, 27, 110, 0.95);
}

.stream-tile-stats-copy-status {
  font-size: 0.68rem;
  color: var(--text);
  opacity: 0.85;
}

@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  .stream-tile-stats-toggle,
  .stream-tile-stats-panel {
    background-color: rgba(18, 24, 58, 0.92);
  }
}

/* --- Playback controls (live-edge pill, scrubber, PiP, shortcuts, toast) --- */

/* Top-right cluster (tile-chrome-fix item 1): `.go-live-button`, `.stream-tile-pip-toggle`, and
   this pill form one horizontal row, all anchored at the same `top: var(--inset-chrome)` as
   `.go-live-button` (rightmost, unchanged) rather than the diagonal stagger they used to have
   (this pill at `top: var(--inset-chrome); right: 3rem`, the PiP toggle below it at
   `top: 2.4rem; right: var(--inset-chrome)`). That stagger passed every rect-intersection check
   (the two boxes never actually overlapped) while still visually colliding -- an ~8px vertical
   overlap band left only a few px of real diagonal clearance between the pill's bottom-left
   corner and the toggle's top-right one, close enough that their borders/shadows read as touching
   on screen. scripts/measure-room-layout.mjs's `runChromeClearanceProbe` now asserts a real
   minimum edge-to-edge gap for exactly this reason (rect-intersection alone already covered by
   this file's other geometry probes was not sufficient).

   Row order, right to left, each gap `var(--inset-chrome)` (this tile's own standing corner-gap
   token -- see its definition's own comment -- reused here rather than inventing a second "gap
   between controls" constant):
     .go-live-button        right: var(--inset-chrome)                              (unchanged)
     .stream-tile-pip-toggle right: var(--inset-chrome) + <go-live reserved> + var(--inset-chrome)
     .live-edge-pill (here)  right: <pip-toggle's right> + <pip reserved> + var(--inset-chrome)

   `.go-live-button` is hidden far more often than not (only shown once a viewer has rewound
   behind the live edge -- see its own rule's comment) but its reserved width is still baked into
   the row's fixed offsets rather than only reserved conditionally: these three are independent
   absolutely-positioned siblings, not flex children, so there is no CSS-only way to have the row
   collapse when one member is hidden without a JS reflow this fix does not add. The reserved
   widths below are real measured content widths (not guesses): `.go-live-button` renders at
   ~66.6px against its own fixed "Go live" text (measured directly against this file's rules in
   isolation), reserved here as 4.5rem/72px; `.stream-tile-pip-toggle` renders at ~35.7px against
   its own fixed "PiP" text (measured via scripts/measure-room-layout.mjs's own chrome-clearance
   probe), reserved here as 2.5rem/40px -- both rounded up a few px for cross-platform font
   rendering margin, on top of the explicit `var(--inset-chrome)` gaps, so real clearance between
   any two row members works out to comfortably more than the probe's own 6px minimum even in the
   worst case (all three visible at once).

   390x844 mobile: this row does NOT wrap or stack -- it stays one row, just moved down as a
   unit. The whole row's real width (live-edge-pill's own ~43px plus the two reserved widths and
   three gaps above) is well under half the tile's width at this viewport, so `right` needed no
   mobile override; scripts/measure-room-layout.mjs's chrome-clearance probe runs this same
   spacing check at 390x844 as well as desktop and asserts the row still clears by the same 6px
   minimum there. `top` DOES get a mobile override, though -- not for spacing between row members,
   but because the unconditional `top: var(--inset-chrome)` left the whole row hidden underneath
   the floating mobile titlebar at this breakpoint (a real, found-by-screenshot defect, not a
   rect-intersection one -- see the `@media (max-width: 900px)` block's own comment on this same
   rule for the measured proof). Only the pre-existing `width: auto` overrides in that block were
   already present; the `top` override is new. */

/* Live-edge pill: green when at live edge, amber when behind with drift readout.
   Positioned inside the tile's grip area, same pattern as the stats toggle. Pill-shaped
   (`--radius-pill`) to read as the same kind of corner control as `.go-live-button` beside it,
   rather than the small square badge it used to be (tile-chrome-fix item 1's Part 2) -- padding,
   colors, and green/amber state behavior are all unchanged, only the corner radius and this row
   position moved. */
.live-edge-pill {
  position: absolute;
  top: var(--tile-chrome-top);
  right: calc(var(--inset-chrome) + var(--pip-width) + 8px);
  z-index: 2;
  height: 28px;
  min-height: unset; /* the generic button min-height (2.55rem) would otherwise win */
  min-width: var(--live-pill-width);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  padding: 0 11px 0 9px;
  font-size: 11px;
  font-weight: 700;
  letter-spacing: .08em;
  line-height: 1;
  font-variant-numeric: tabular-nums;
  text-align: center;
  border: 1px solid rgba(101,240,197,.5);
  border-radius: var(--radius-pill);
  cursor: pointer;
  background: rgba(101,240,197,.14);
  color: var(--success);
  box-shadow: inset 0 1px 0 rgba(255,255,255,.2), 0 0 18px rgba(101,240,197,.28);
  backdrop-filter: var(--blur-chrome);
  transition: background-color var(--motion-base) var(--ease-out), color var(--motion-base) var(--ease-out), border-color var(--motion-base) var(--ease-out), box-shadow var(--motion-base) var(--ease-out);
}

.live-edge-pill::before {
  content: '';
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: currentColor;
  box-shadow: 0 0 8px currentColor;
  flex: none;
}

.live-edge-pill.behind-live {
  background: rgba(255,211,106,.14);
  border-color: rgba(255,211,106,.55);
  box-shadow: inset 0 1px 0 rgba(255,255,255,.2), 0 0 18px rgba(255,211,106,.25);
  color: var(--warning);
}

.live-edge-pill:hover,
.live-edge-pill:focus-visible {
  background: rgba(30, 110, 70, 0.95);
}

.live-edge-pill.behind-live:hover,
.live-edge-pill.behind-live:focus-visible {
  background: rgba(150, 100, 30, 0.95);
}

/* Motion addendum: "Pill state change (green<->amber): color/opacity only, no pulsing loop" --
   the color-only transition above already satisfies that; this is the reduced-motion override
   the addendum separately requires for every animated control this bundle touches, this pill
   named explicitly among them. Found missing (the scrubber/cheat-sheet/toast below each already
   had their own) while doing a final pass for standing-constraint coverage; added here rather
   than left as a silent gap. */
@media (prefers-reduced-motion: reduce) {
  .live-edge-pill {
    transition: none;
  }
}

/* Playback scrubber: minimal range input over the buffered/seekable range. Shown only when paused
   or behind live (isAtLiveEdge, room-playback-controls.mjs), hidden at the live edge.
   `bottom: 4.5rem` positions it clear of the native <video controls> band (~56-70px from tile
   bottom) -- the scrubber-defect diagnosis (H3) found this bar previously painting INSIDE that
   band, double-painting a second bar/thumb over the native seek bar and, worse (S3),
   INTERCEPTING pointer events meant for the native volume slider: dragging the volume slider
   actually dragged this input instead. Moving the whole container above the band fixes both --
   the two controls no longer share any pixels, so there is nothing left to double-paint or steal
   clicks from. `left: 0; right: 0` (no side inset): the scrubber must span the full picture width
   (item 2 of the diagnosis's fix requirements), and `.stream-tile video` is itself `width: 100%`
   with no padding on `.stream-tile` -- see scripts/measure-room-layout.mjs's live-edge-pill probe
   for the width-vs-video assertion this satisfies. */
.playback-scrubber-container {
  position: absolute;
  /* Design pass 2: 22px in from the tile edges (the native control bar's own padding). 70px up,
     not the artboard's 62px: Chromium's real control band is taller than the mock's 56px, and the
     harness's native-band overlap check is the gate. */
  bottom: 70px;
  left: 22px;
  right: 22px;
  z-index: 2;
  height: 12px;
  display: flex;
  align-items: center;
  transform: translateY(0);
  opacity: 1;
  transition: opacity var(--motion-base) var(--ease-out), transform var(--motion-base) var(--ease-out);
}

/* Same trap as button[hidden] (this file's own comment above it, ~line 704): a blanket author
   rule beats a user-agent [hidden]{display:none} rule regardless of specificity, so a hidden
   control needs its OWN display:none, not just the [hidden] attribute -- this rule supplies it
   for the container, plus pointer-events:none so a hidden scrubber can never be hit-tested even
   if some future rule re-enables its display by mistake. */
.playback-scrubber-container[hidden] {
  display: none;
  pointer-events: none;
}

/* The input needs its OWN copy of the rule above, not just the container's: a descendant of a
   display:none ancestor still reports its OWN specified display via getComputedStyle (verified
   against real Chromium -- browsers do not rewrite a hidden subtree's computed style to 'none'),
   so a check that reads the input's computed style directly, rather than walking up to the
   hidden container, would otherwise see something other than 'none' even though nothing actually
   paints. pointer-events:none is already inherited from the container rule above (pointer-events
   is an inherited property), but is repeated here so the input's own computed value is 'none'
   too, matching the fix requirement literally ("container AND input"). */
.playback-scrubber-container[hidden] .playback-scrubber {
  display: none;
  pointer-events: none;
}

@media (prefers-reduced-motion: reduce) {
  .playback-scrubber-container {
    transition: none;
  }
}

.playback-scrubber {
  -webkit-appearance: none;
  appearance: none;
  width: 100%;
  min-height: 0;
  height: 6px;
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: 999px;
  /* Track: dim rail with a violet-to-amber fill up to the current position; room-playback-controls.mjs keeps --scrub-progress in sync with the input value. */
  background: linear-gradient(90deg, #a06cff, #ffd36a) 0 0 / var(--scrub-progress, 0%) 100% no-repeat, rgba(255,255,255,.18);
  cursor: pointer;
  outline-offset: 4px;
}

.playback-scrubber::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: 14px;
  height: 14px;
  border-radius: 50%;
  border: 0;
  background: #fff;
  box-shadow: 0 0 0 2px rgba(5,7,17,.6), 0 0 10px rgba(255,211,106,.8);
  cursor: pointer;
}

.playback-scrubber::-moz-range-thumb {
  width: 14px;
  height: 14px;
  border-radius: 50%;
  border: 0;
  background: #fff;
  box-shadow: 0 0 0 2px rgba(5,7,17,.6), 0 0 10px rgba(255,211,106,.8);
  cursor: pointer;
}

/* Picture-in-Picture toggle. Same pattern as stats toggle. Middle of the top-right row
   (tile-chrome-fix item 1) -- see `.live-edge-pill`'s own comment above for the full row layout
   and the diagonal-stagger defect this replaced (this control used to sit at `top: 2.4rem`, a
   full row below `.go-live-button`, which was the other half of that same near-collision). */
.stream-tile-pip-toggle {
  position: absolute;
  top: var(--tile-chrome-top);
  right: var(--inset-chrome);
  z-index: 2;
  height: 28px;
  min-width: var(--pip-width);
  min-height: unset;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0 11px;
  font-size: 11px;
  font-weight: 700;
  letter-spacing: .04em;
  line-height: 1;
  background: linear-gradient(180deg, rgba(255,255,255,.26), rgba(255,255,255,.04) 50%, rgba(255,255,255,0) 52%), rgba(160,108,255,.55);
  color: var(--text);
  border: 1px solid rgba(217,183,255,.6);
  border-radius: var(--radius-pill);
  box-shadow: inset 0 1px 0 rgba(255,255,255,.45), 0 2px 8px rgba(0,0,0,.35);
  cursor: pointer;
  transition: background-color var(--motion-base) var(--ease-out);
}

.stream-tile-pip-toggle:hover,
.stream-tile-pip-toggle:focus-visible,
.stream-tile:hover .stream-tile-pip-toggle {
  background: rgba(70, 60, 140, 0.95);
}

.stream-tile-pip-toggle[hidden] {
  display: none;
}

/* --- Self-view mode (self-view-mode.mjs) --- */
/* Shared cosmetic base for both self-view buttons: `createSelfViewToggleButton`
   (room-grid.mjs) always sets `class="aero-button self-view-toggle"` on whichever of the two it
   builds -- "Show in grid" on the self-preview frame (`.self-preview-tile .self-view-toggle`
   below) and its inverse, "Shrink to frame", on the streamer's own tile in the main grid
   (`.stream-tile .self-view-toggle` below). Same corner-pill idiom as every other per-tile chip
   on this page (`.stream-tile-stats-toggle`, `.stream-tile-grip`): unset the generic full-size
   `button` min-height, pill radius, translucent glass background. Positioning is deliberately
   NOT shared between the two selectors below -- the two containers have completely different
   existing chrome to stay clear of (see each rule's own comment), so there is no single `top`/
   `right` pair that would be correct in both places. */
.self-view-toggle {
  min-height: unset;
  border: 1px solid rgba(255,255,255,0.32);
  border-radius: var(--radius-pill);
  padding: 0.28rem 0.65rem;
  color: #eaf6ff;
  background: rgba(14, 29, 58, 0.72);
  box-shadow: inset 0 1px rgba(255,255,255,0.22);
  font-size: 0.72rem;
  font-weight: 800;
  backdrop-filter: blur(12px);
  cursor: pointer;
}

.self-view-toggle:hover,
.self-view-toggle:focus-visible {
  background: rgba(14, 29, 58, 0.92);
}

/* Same focus-ring convention as `.stream-tile-grip:focus-visible`/`.stream-tile-stats-
   toggle:focus-visible` above, not a bespoke treatment for this one control. */
.self-view-toggle:focus-visible {
  outline: 2px solid var(--accent-2);
  outline-offset: 2px;
}

/* "Show in grid" on the self-preview frame: this small tile has none of the main grid's top-right
   chrome at all (renderSelfPreview never calls createGoLiveButton/createLiveEdgePill/
   createPiPButton/createScrubber -- see room-grid.mjs), and only two things already live in its
   top-left corner (the "Your stream" badge, `.self-preview-tile::before`, and the stats toggle,
   `.stream-tile-stats-toggle`'s own unconditional rule) -- top-right looks free by DOM-presence
   reasoning alone. It is NOT actually free, though: `.self-preview` sits at the very top of the
   room's content column, which the floating `#room-titlebar` overlay (`position: absolute`,
   `top: 0`, its own real height ~44px -- see the room-chrome section's own comment on why content
   is deliberately not pushed down to clear it) paints directly over. A real Playwright click at
   `top: var(--inset-chrome)` here was actually intercepted by the titlebar in
   scripts/measure-room-layout.mjs's self-view probe -- found by running the harness, not by
   reasoning about the CSS alone, which is exactly the trap AGENTS.md's own "container-geometry
   assertion can pass while the user sees nothing change" warning describes (a DOM-presence check
   would have reported this button as present and clickable). Anchored from the BOTTOM instead:
   the self-preview tile is always comfortably taller than the titlebar (its own 16:9 aspect ratio
   against a >=240px width already clears ~135px, more than double the titlebar's height), so a
   bottom-anchored corner can never land under it regardless of exactly how tall the tile renders.
   z-index 4, the same as the stats toggle/panel and one above `.self-preview-play` (z-index 3,
   `inset: 0` over the whole tile) -- without this the button would be visually present but
   unclickable, buried under the "Play your stream" overlay, until the streamer had already
   pressed it once. */
.self-preview-tile .self-view-toggle {
  position: absolute;
  z-index: 4;
  bottom: var(--inset-chrome);
  right: var(--inset-chrome);
}

/* "Shrink to frame" on the streamer's own tile in the main grid: renderStreamTiles only ever
   appends this button to the one tile whose stream matches `options.ownStreamPath` (self-view
   mode is only ever GRID_MODE for at most one tile in the whole grid at a time -- see
   room-grid.mjs's own comment), so this selector, unlike every other per-tile chrome rule on this
   page, only ever paints on a single tile at once, never on "every tile" the way `.stream-tile-
   grip`/`.stream-tile-stats-toggle` do.
   Every other corner is already spoken for on an ordinary tile: top-left is the stats toggle (and,
   lower, the host label), top-center is the arrangement grip, and top-right is already a row of
   up to three controls anchored from `.go-live-button` (tile-chrome-fix item 1) --
   `.stream-tile-pip-toggle`, then `.live-edge-pill`. This joins that same row as its next member,
   following its own exact idiom (each reserves a fixed width, in the row's own units, so `right`
   is computed relative to every member already anchored further right) rather than inventing a
   fifth, independent position: `.live-edge-pill` renders at ~43px (its own comment) -- reserved
   here as 3rem/48px, rounded up for the same cross-platform font-rendering margin its own two
   reserved widths already are. Because this selector only ever matches the one own-tile, joining
   the row costs every OTHER tile in the grid nothing at all -- their own `.live-edge-pill`/
   `.stream-tile-pip-toggle`/`.go-live-button` positions are completely untouched. See the
   `@media (max-width: 900px)` override further down for why this specific member of the row moves
   to its own line on a narrow phone instead of staying in it. */
.stream-tile .self-view-toggle {
  position: absolute;
  z-index: 2;
  top: var(--tile-chrome-top);
  right: calc(var(--inset-chrome) + var(--pip-width) + 8px + var(--live-pill-width) + 8px);
  height: 28px;
  min-height: unset;
}

/* Keyboard shortcuts cheat sheet: focus-trapped overlay. */
.keyboard-cheatsheet {
  position: fixed;
  inset: 0;
  z-index: 1000;
  background: rgba(0, 0, 0, 0.8);
  display: flex;
  align-items: center;
  justify-content: center;
  opacity: 1;
  transform: scale(1);
  transition: opacity var(--motion-base) var(--ease-out), transform var(--motion-base) var(--ease-out);
}

.keyboard-cheatsheet[hidden] {
  display: none;
}

.keyboard-cheatsheet-panel {
  background: var(--bg-1);
  border: 1px solid var(--glass-edge);
  border-radius: var(--radius-lg);
  padding: 2rem;
  max-width: 90vw;
  max-height: 80vh;
  overflow-y: auto;
  box-shadow: 0 20px 60px var(--shadow);
}

.keyboard-cheatsheet h2 {
  margin-top: 0;
  margin-bottom: 1.5rem;
  font-size: 1.2rem;
}

.keyboard-cheatsheet-list {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 1rem 2rem;
  margin-bottom: 1.5rem;
}

.keyboard-cheatsheet-list dt {
  font-weight: 600;
  font-family: "SF Mono", Monaco, "Cascadia Code", monospace;
  font-size: 0.9rem;
  color: var(--accent-2);
}

.keyboard-cheatsheet-list dd {
  margin: 0;
  font-size: 0.9rem;
  color: var(--text);
}

.keyboard-cheatsheet-close {
  display: block;
  margin: 1rem auto 0;
  padding: 0.5rem 1rem;
  background: var(--accent);
  color: white;
  border: none;
  border-radius: 0.4rem;
  cursor: pointer;
  font-size: 0.9rem;
  transition: background-color var(--motion-base) var(--ease-out);
}

.keyboard-cheatsheet-close:hover,
.keyboard-cheatsheet-close:focus-visible {
  background: #b87fff;
}

@media (prefers-reduced-motion: reduce) {
  .keyboard-cheatsheet {
    transition: none;
  }
  .keyboard-cheatsheet-close {
    transition: none;
  }
}

/* Connection health toast: rate-limited warning for poor connection.
   Positioned at bottom-left, non-modal, dismissable. */
.connection-health-toast {
  position: fixed;
  bottom: 1rem;
  left: 1rem;
  z-index: 999;
  display: flex;
  align-items: center;
  gap: 0.75rem;
  background: rgba(220, 100, 30, 0.95);
  color: white;
  padding: 1rem 1.2rem;
  border-radius: var(--radius-md);
  box-shadow: 0 10px 30px var(--shadow);
  opacity: 1;
  transform: translateY(0);
  transition: opacity var(--motion-base) var(--ease-out), transform var(--motion-base) var(--ease-out);
  animation: toastEnter var(--motion-base) var(--ease-out);
}

.connection-health-toast[hidden] {
  display: none;
}

/* Exit half of the motion addendum's "Toast: translateY rise + fade in, fade out" -- the enter
   animation above (`toastEnter`) already covers the rise+fade-in; this covers fade-out.
   createHealthToastController (room-playback-controls.mjs) adds this class synchronously on
   dismiss, WHILE the toast is still visible (not yet `[hidden]`), specifically so this transition
   has a real "opacity: 1; transform: translateY(0)" starting frame to animate away from -- only
   after this transition has had time to play (its own `exitAnimationMs`, matching
   `--motion-fast` below) does the controller actually set `[hidden]`. Higher specificity
   (0,2,0) than the base `.connection-health-toast` rule's own transition (0,1,0), so this
   overrides its timing without needing `!important`; exit uses `--motion-fast`/`--ease-in`, per
   the addendum's "Exit: var(--motion-fast) var(--ease-in)" rule for every animated control in
   this bundle. */
.connection-health-toast.leaving {
  opacity: 0;
  transform: translateY(20px);
  transition: opacity var(--motion-fast) var(--ease-in), transform var(--motion-fast) var(--ease-in);
}

@keyframes toastEnter {
  from {
    opacity: 0;
    transform: translateY(20px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.connection-health-toast-dismiss {
  padding: 0.3rem 0.6rem;
  background: rgba(255, 255, 255, 0.2);
  color: white;
  border: 1px solid rgba(255, 255, 255, 0.3);
  border-radius: 0.3rem;
  cursor: pointer;
  font-size: 0.75rem;
  transition: background-color var(--motion-base) var(--ease-out);
}

.connection-health-toast-dismiss:hover,
.connection-health-toast-dismiss:focus-visible {
  background: rgba(255, 255, 255, 0.3);
}

@media (prefers-reduced-motion: reduce) {
  .connection-health-toast {
    transition: none;
    animation: none;
  }
  .connection-health-toast-dismiss {
    transition: none;
  }
  /* Higher specificity than the plain .connection-health-toast rule above (0,2,0 vs 0,1,0) --
     without a reduced-motion override of its own, `.leaving`'s own `transition` declaration
     (added outside this media query, for every viewer) would still win the cascade and play a
     120ms fade even here. Appear/disappear instantly instead -- "functional, not gliding", per
     the motion addendum. */
  .connection-health-toast.leaving {
    transition: none;
  }
}

@media (max-width: 600px) {
  .connection-health-toast {
    bottom: 0.5rem;
    left: 0.5rem;
    right: 0.5rem;
    width: auto;
  }

  .keyboard-cheatsheet-panel {
    max-width: 95vw;
    padding: 1.5rem;
  }
}

@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  .keyboard-cheatsheet-panel {
    background-color: rgba(11, 16, 40, 0.92);
  }
}

/* The "landing preview" dim-the-source-tile cue while a tile is actually being dragged --
   `data-dragging` is set by room-grid.mjs's wirePromote on `dragstart` and cleared on
   `dragend`/`drop` (see its own comments there). `dataset`, not a class, for the same reason
   `video.dataset.playerFailed` already uses it elsewhere in this stylesheet's own JS: room-
   grid.mjs's unit tests drive plain-object fake DOM elements that only implement `dataset`, never
   `classList`. */
.stream-tile[data-dragging="true"] {
  cursor: grabbing;
  opacity: 0.55;
  outline: 2px dashed rgba(130, 200, 255, 0.5);
}

/* Aero Snap-style drag preview: two translucent zones overlaid on the video stage while a tile is
   being dragged, showing the focus/rail split that will result on drop -- see room.html's static
   `#arrangement-snap-overlay` markup and room.js's showDragPreview/hideDragPreview (wired to
   wirePromote's onDragStart/onDragEnd above). `aria-hidden="true"` in the markup is deliberate:
   this is a purely visual/pointer-user affordance, and the accessible narrative for keyboard/AT
   users comes entirely from `#arrangement-announcer` and the grip's own aria-label/
   aria-describedby, neither of which depends on this overlay existing at all. */
.arrangement-snap-overlay {
  position: absolute;
  inset: 0;
  /* Above everything else in the tile, including `.stream-tile-interrupted`'s z-index: 3 -- the
     preview must read clearly even over a tile currently showing that panel. */
  z-index: 5;
  display: flex;
  flex-direction: row;
  gap: 0.85rem;
  /* CRITICAL: must never intercept dragover/drop -- those still need to land on the tiles
     underneath. If this is ever not pointer-events: none, dropping breaks entirely. */
  pointer-events: none;
}

.arrangement-snap-overlay[hidden] {
  display: none;
}

.snap-zone {
  display: flex;
  align-items: center;
  justify-content: center;
  border: 2px dashed rgba(130, 200, 255, 0.65);
  border-radius: var(--radius-lg);
  background: linear-gradient(180deg, rgba(98, 199, 255, 0.22), rgba(98, 199, 255, 0.08));
  backdrop-filter: blur(4px);
  color: #eaf6ff;
  font-weight: 800;
  font-size: 0.85rem;
  text-shadow: 0 1px 3px var(--shadow);
}

.snap-zone-focus {
  flex: 1 1 auto;
}

/* Mirrors `.arranged-rail`'s real width (13rem, above) so the preview matches the layout that
   will actually result on drop, rather than an approximation. */
.snap-zone-rail {
  flex: 0 0 13rem;
  background: linear-gradient(180deg, rgba(160, 108, 255, 0.2), rgba(160, 108, 255, 0.08));
  border-color: rgba(160, 108, 255, 0.55);
}

.empty-state {
  margin: 0;
  color: var(--muted);
  text-align: center;
}

.self-preview {
  width: min(240px, 45vw);
  margin: 0 0 1rem;
}

.self-preview-tile {
  position: relative;
  overflow: hidden;
  border: 1px solid rgba(215, 231, 255, 0.35);
  border-radius: var(--radius-lg);
  background:
    linear-gradient(180deg, rgba(255,255,255,0.12), transparent 34%),
    rgba(5, 9, 24, 0.66);
  box-shadow: 0 12px 30px rgba(0,0,0,0.4), inset 0 1px rgba(255,255,255,0.2);
}

.self-preview-tile::before {
  content: "Your stream";
  position: absolute;
  z-index: 2;
  top: 0.5rem;
  left: 0.5rem;
  border: 1px solid rgba(255,255,255,0.32);
  border-radius: var(--radius-pill);
  padding: 0.2rem 0.5rem;
  color: #e9fff8;
  background: rgba(14, 29, 58, 0.72);
  font-size: 0.68rem;
  font-weight: 850;
  backdrop-filter: blur(10px);
}

.self-preview-tile video {
  width: 100%;
  height: 100%;
  aspect-ratio: 16 / 9;
  background: #000;
  display: block;
  /* Same fit/fill treatment as `.stream-tile video` above - see that
     rule's comment. */
  object-fit: contain;
}

/* The self-preview tile is smaller than a grid tile, so its label chip sits
   closer to the "Your stream" badge above it and uses a smaller footprint. */
.self-preview-tile .stream-tile-label {
  top: 2rem;
  left: 0.5rem;
  max-width: calc(100% - 1rem);
  padding: 0.18rem 0.45rem;
  font-size: 0.62rem;
}

.self-preview-play {
  position: absolute;
  inset: 0;
  z-index: 3;
  width: 100%;
  height: 100%;
  min-height: unset;
  border-radius: 0;
  border: none;
  color: white;
  font-size: 0.85rem;
  font-weight: 800;
  background: rgba(6, 10, 26, 0.55);
  backdrop-filter: blur(2px);
}

.self-preview-play:hover {
  background: rgba(6, 10, 26, 0.4);
}

.stream-grid-status {
  min-height: 1.4rem;
  margin: 0.75rem 0 0;
  color: var(--muted);
  font-size: 0.9rem;
  text-align: center;
}

/* Room chat: video left, chat right (see room.html — both live in one
   .aero-content.room-columns row now, not stacked sections). Neither
   affects the grid's own sizing; computeGridClass/renderStreamTiles are
   untouched by any of this. See docs/comet/changes/room-chat/specs/
   room-chat/spec.md and docs/comet/specs/room-view-presentation/spec.md
   ("Layout"). `align-items: stretch` (the flex default, not overridden
   here) is what makes `.chat-column` match the row's height, but
   `.chat-column` itself has no in-flow content (see below), so it can
   never be the thing that sets that height. Below 901px the video's own
   aspect-ratio-derived height still sets the row's height, exactly as
   before (docs/comet/changes/chat-column-height/brief.md). Above it,
   `.room-window`'s viewport-bounded height (see the `.room-shell` rule
   above) sets it instead, via `flex: 1 1 auto; min-height: 0` here -
   the one place in the row that is allowed to grow or shrink to the
   space `.room-titlebar` leaves - so the row's height is no longer only
   the video's doing, but it is still a single, definite, non-competed-over
   quantity by the time `.aero-player-stage` and `.chat-column` see it. See
   docs/comet/changes/room-adaptive-fill/brief.md. `--chat-width` is shared
   with `.chat-column`'s flex-basis and the desktop-only
   `.aero-player-stage` max-width below, so the three numbers can never
   drift out of sync; `--room-gap` does the same for the gap between them. */
.room-columns {
  display: flex;
  gap: var(--room-gap);
  padding: var(--room-pad);
  flex: 1 1 auto;
  min-height: 0;
  /* clamp() keeps the sidebar from both vanishing on a narrow window and
     ballooning on a very wide one - see "Scale the chat column within
     limits" in docs/comet/changes/room-adaptive-fill/brief.md. Only its
     value matters below 901px is moot: the narrow-viewport media query
     overrides `.chat-column`'s flex-basis outright (see below). Design-pass 1:
     updated to 21% and 400px max per the design brief. */
  --chat-width: clamp(320px, 21%, 400px);
}

/* Above 901px the chat column scales within limits instead of holding a
   fixed 360px, so a 34-inch display doesn't show a phone-width sidebar
   next to a large video - see specs/room-view-presentation/spec.md
   ("Decisions"). Below that width the narrow-viewport media query
   replaces this flex-basis outright (full width, natural height), so
   `--chat-width` is inert there. It is `position: relative` purely to be
   `.chat-panel`'s containing block, and it deliberately carries no content
   of its own: `.chat-panel` is its only child and is taken out of flow
   below, so `.chat-column`'s own content height is always zero and
   `align-items: stretch` on `.room-columns` is the only thing that ever
   gives it a height - the row's. A message list long enough to be taller
   than the video can therefore never grow this column or the row; it has
   nowhere to push. */
.chat-column {
  position: relative;
  flex: 0 0 var(--chat-width);
  min-width: 0;
}

/* Absolutely positioned to fill `.chat-column` exactly (`inset: 0`) instead
   of being an ordinary in-flow flex child of `.room-columns` as before: an
   absolutely positioned box cannot contribute to its containing block's
   auto height, which is what stops a tall message list from growing
   `.chat-column`/`.room-columns` past the video column's aspect-ratio
   height. Still a flex column internally: the heading, status line and
   composer size to their own content and never shrink (flex: 0 0 auto
   below), and #chat-messages is the one child that grows or shrinks to take
   up whatever height that leaves within this panel's own (now
   video-column-matched, via `.chat-column`) height - which is exactly what
   finally gives `overflow-y: auto` below a box it can scroll inside. See
   docs/comet/changes/chat-column-height/brief.md. */
.chat-panel {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  gap: 0;
  padding: 0;
  overflow: hidden;
  /* Design-pass 1: GLASS PANEL styling */
  background: var(--glass-lit), rgba(28,36,76,.48);
  border: 1px solid var(--edge);
  box-shadow: inset 0 1px 0 rgba(232,239,255,.35), 0 18px 48px rgba(0,0,0,.45);
  backdrop-filter: var(--blur-panel);
  border-radius: var(--radius-panel);
}

/* Chat collapse (chat-collapse): `[hidden]` alone is not enough to hide `.chat-panel` - the
   plain `.chat-panel { display: flex }` above (and its narrow-viewport `position: static`
   counterpart below) is an author rule with higher specificity than the UA stylesheet's own
   `[hidden] { display: none }`, so without this it would win and the "hidden" panel would still
   render. Combining the attribute selector with the class here (specificity 0,2,0) beats the
   plain class alone (0,1,0) regardless of which media query last touched `display`, so one rule
   covers both breakpoints. See chat.js's own comments for what sets/clears `[hidden]`. */
.chat-panel[hidden] {
  display: none;
}

/* Motion: chat expand slide + fade (motion-symmetry pass, item 3). Expand-only, deliberately
   asymmetric with collapse -- see the paragraph below for why. `.chat-panel` shows/hides via the
   native `hidden` attribute (chat.js's applyChatCollapseState, untouched by this pass --
   `chatPanel.hidden = chatCollapsed` still flips synchronously, exactly as chat.test.mjs's own
   assertions require), which the rule above computes to `display: none` the instant it's set.
   `.chat-panel-enter` is a state-change class chat.js's showChatExpandAnimation applies in the
   SAME call that clears `hidden`, then removes a paint later (the same double-rAF technique used
   throughout this pass -- see room-grid.mjs's scheduleTileEnter) -- since `hidden` is already
   false by the time this class is applied, the panel is genuinely laid out and rendered, and the
   class's opacity/transform simply need a coordinated fade-out one frame later, no bridging of
   `display: none` required.

   Collapse (hiding) does NOT get the same treatment, and there is no `[hidden]`-side transition
   here at all: an `allow-discrete`/`@starting-style` attempt at symmetric slide-out was tried and
   reverted -- it works cosmetically, but `allow-discrete` keeps `.chat-panel` occupying its full,
   expanded layout box for the whole transition duration *after* `hidden` is already set, which
   scripts/measure-room-layout.mjs's docs/backlog.md item 19 guard (the room page must never
   scroll while chat is collapsed) caught immediately: the page overflowed and scrolled for that
   entire window on every stream count. Collapsing snaps instantly, the same reasoning
   `.aero-player-stage` below already uses for its own width change -- both are part of the same
   reflow this toggle causes, and neither can risk a transition that keeps stale layout alive a
   moment longer than the `hidden` attribute already says it should be. */
body.motion-ready .chat-panel.chat-panel-enter {
  opacity: 0;
  transform: translateX(12px);
}

body.motion-ready .chat-panel {
  transition: opacity var(--motion-base) var(--ease-out), transform var(--motion-base) var(--ease-out);
}

@media (prefers-reduced-motion: reduce) {
  /* Same specificity-matching fix as `.stream-tile`'s own reduced-motion override above -- see
     its comment. */
  body.motion-ready .chat-panel {
    transition: none;
  }
}

.chat-panel h2 {
  margin: 0;
  font-weight: 600;
  font-size: 16px;
  line-height: 1.2;
  flex: 0 0 auto;
  padding: 14px 7rem 0 16px;
  /* As tall as the "Hide chat" pill that floats on this row (top 14px, 30px tall), so the notice
     below starts under the pill instead of running behind it. */
  min-height: calc(14px + var(--pill-h));
  box-sizing: border-box;
  display: flex;
  align-items: center;
}

/* Static ephemerality disclosure (room.html's #chat-messages sibling comment
   has the full "why here, why static" reasoning). `flex: 0 0 auto` matches
   every other fixed-size child of `.chat-panel` (h2 above, .chat-status and
   .chat-form below) - #chat-messages is the only child allowed to grow or
   shrink, so this line only ever costs message-list height, never composer
   visibility, at any viewport including 390x844. See
   docs/comet/specs/room-chat/spec.md ("Retention is disclosed, not merely
   accepted"). Design-pass 1: updated styling and sizing. */
.chat-notice {
  flex: 0 0 auto;
  margin: 0;
  font-size: 12px;
  line-height: 1.4;
  color: var(--dim);
  padding: 0 16px 10px 16px;
  border-bottom: 1px solid var(--edge-soft);
}

/* The message list is the one part of the chat column allowed to grow or
   shrink: `flex: 1 1 auto` lets it take whatever height is left once the
   heading, status line and composer (all `flex: 0 0 auto`) have theirs.
   `min-height: 0` here is the classic flex/grid fix - a flex item's
   automatic minimum height is its content's size, which would otherwise
   stop this from shrinking below its content and push the whole chat panel
   taller than its box instead of scrolling in place. The height it is
   fitting into comes from `.chat-panel`'s `position: absolute; inset: 0`
   sizing to `.chat-column`, which itself stretches to the row's height
   (see both above) - below 901px that row's height is still only the
   video's aspect-ratio doing, exactly as before; at or above it, it comes
   from `.room-shell`'s `dvh` (see that rule near the top of this file) by
   way of the flex-column chain, not from a `vh`/`dvh` unit written on this
   rule or any of its ancestors in this chain - this is still the one place
   that absorbs the difference, not a second viewport-coupled region
   competing with it.

   Bottom-anchoring a short conversation against the composer (rather than stranding it under a
   column of empty space at the top - see specs/room-view-presentation/spec.md's "Chat"
   acceptance example) does NOT use `align-content: end` here, even though that is the obvious
   CSS Grid tool for it. It shipped that way from 2026-08-13 (theater-as-default, this rule's own
   git history) through v1.18.1 and broke real scrolling in production: a scroll container that
   packs its content away from the start edge via `align-content`/`justify-content` does not
   register the start-side overflow as *scrollable* overflow in Chromium - once the list actually
   overflowed, `scrollHeight` kept reporting exactly `clientHeight` (verified directly - a
   30-message list overflowing by ~2000px still measured `scrollHeight === clientHeight`), so
   there was nothing for `overflow-y: auto` to scroll to: no scrollbar, wheel events did nothing,
   and `el.scrollTop = 0` was a no-op because 0 was already the only reachable position. This is
   exactly backwards from the comment this replaced, which assumed "a full or overflowing list
   already fills the box and has nothing left for this property to distribute" - the property was
   never idle once full; it was actively hiding the overflow from the browser's own scroll
   machinery.

   The fix drops `display: grid` for `display: flex; flex-direction: column`, keeps ordinary
   start-packing (a plain flex container's default `justify-content: flex-start` - the one shape
   Chromium always accounts for correctly in its scrollable-overflow rectangle, the same as grid's
   `align-content: start`), and instead gives the FIRST message a leading `margin-top: auto`
   (below) to reproduce the bottom-anchored look. This is deliberately flexbox, not grid, for that
   auto margin to actually do anything: a grid item's `margin: auto` only absorbs leftover space
   *within its own track*, and an implicit, content-sized ("auto") grid row has no leftover space
   of its own - all of a short conversation's leftover height piles up *after* the last track
   regardless of which item requests the auto margin (confirmed empirically - the scratch harness
   below still measured every message packed against the container's top edge with `margin-top:
   auto` applied under `display: grid`, zero visible effect). Flexbox's free-space model is
   different in exactly the way this needs: leftover main-axis space belongs to the flex
   container's overall size calculation, not to any one item's own box, so an auto margin on a
   flex item can intercept it directly - this is the standard, widely-documented "pin flex content
   to one end, but let it overflow the other way and stay genuinely scrollable" technique, and it
   is why this fix is flexbox specifically rather than a grid variant of the same idea.

   With a short conversation, the first message's auto margin absorbs the container's entire
   leftover main-axis space, pushing every message down against the composer - the same visual
   result `align-content: end` used to produce. The moment the list is tall enough to fill or
   overflow the box, there is no free space left for that auto margin to absorb, so it resolves to
   0 and the list is ordinary, top-anchored, fully in-flow content - which is what makes the
   overflow scrollable again. Confirmed empirically (scratch harness, not merely reasoned):
   overriding just the grid-era `align-content: end` back to `align-content: start` (before this
   rule was rewritten to flexbox) fixed `scrollHeight`/wheel-scroll/`scrollTop` reachability
   outright on its own, with no other rule in this block involved (a `min-height: auto` control on
   the same broken `align-content: end` changed nothing) - isolating alignment-away-from-start,
   not `display: grid` itself, as the actual cause of the unreachable overflow; switching to
   flexbox afterward was purely to make the bottom-anchor auto-margin trick above work, not a
   second attempt at the scrolling fix. Scroll-follow behaviour is unaffected by any of this -
   `isAtLatest`/`scrollToLatest` in chat.mjs only read and write `scrollTop`/`scrollHeight`/
   `clientHeight`, and this fix is what makes those values correct instead of stuck. */
.chat-messages {
  list-style: none;
  margin: 0;
  padding: 12px 12px 8px;
  display: flex;
  flex-direction: column;
  gap: 6px;
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
}

/* The bottom-anchor half of the fix above: absorbs leftover flex main-axis space ahead of the
   first message so a short conversation still sits against the composer, without packing the
   list away from its start edge (see that rule's comment for why that breaks scrolling). Scoped
   to `.chat-message` specifically, not `:first-child` alone, so it only ever targets a real
   rendered message - the `:empty` state below has no `<li>` children at all (its placeholder is a
   `::before` pseudo-element, not a `.chat-message`), so this selector simply does not match there
   and does not need to coordinate with `.chat-messages:empty`'s own `justify-content: center`.
   chat.js/chat.mjs's renderMessages always replaces the full child set with
   `<li class="chat-message ...">` nodes (see its own comment), so "first child" and "first
   `.chat-message`" are the same element in practice; this selector is written against the class
   rather than `li:first-child` only because it is the more self-documenting of two equivalent
   options, not because the two selectors would ever disagree here. */
.chat-messages > .chat-message:first-child {
  margin-top: auto;
}

/* Chat void (room-layout-defects): a genuinely empty message list previously rendered as a bare
   rectangle of nothing - measured at 474px of blank space directly under `.chat-notice` at
   1999x837 - reading as broken, not merely quiet. `:empty` matches only when there are zero `<li>`
   children (chat.mjs's renderMessages never leaves stray whitespace text nodes behind, and
   room.html's `<ul id="chat-messages" ...></ul>` has none to start with), so this never fires once
   even one message has posted. Deliberately scoped to the fully-empty case only: with 1-2
   messages the `margin-top: auto` rule above already bottom-anchors them next to the composer,
   which is the spec's own stated correct look for a short conversation
   (docs/comet/specs/room-view-presentation/spec.md, "What has not changed") - not a defect to
   paper over. `justify-content: center` (the flexbox main-axis equivalent of grid's
   `align-content: center` this replaced when `.chat-messages` became a flex container - see that
   rule's own comment) centers the placeholder because there is nothing to anchor to the bottom of
   an empty list; centering it in the void reads as deliberate instead of stranding it up against
   the notice above or the composer below. Generated content, not a chat.js/chat.mjs-authored DOM
   node - no script needs to know about this state, and it can never go stale relative to what
   renderMessages actually did to the list. */
.chat-messages:empty {
  justify-content: center;
}

.chat-messages:empty::before {
  content: "No messages yet — say hello.";
  padding: 0 1rem;
  color: var(--dim);
  font-size: 0.82rem;
  text-align: center;
  line-height: 1.4;
}

/* New-messages pill (chat-new-messages-pill): chat.js/chat.mjs deliberately never yank a reader
   who has scrolled up to read history back down to the newest message (see isAtLatest/
   scrollToLatest's own comments further up this file's chat.mjs counterpart) - this is the
   affordance that tells them messages kept arriving underneath them anyway, without moving
   anything on their own. Created only when actually needed and fully removed (not merely
   hidden) once dismissed - see chat.js's resetNewMessagesPill for why: a lingering hidden child
   here would defeat `.chat-messages:empty` above, which is keyed on the `<ul>` having zero DOM
   children at all, not zero *visible* ones.

   Design-pass 1 restyled the pill (gradient primary look - .chat-new-messages-pill-button below)
   but kept this sticky-bottom placement: the pill is the LAST child of the scrolling list, so
   `position: absolute` would scroll away with the history instead of staying in view while the
   reader is scrolled up, which is the one moment the pill exists for. */

.chat-new-messages-pill {
  position: sticky;
  bottom: 0.4rem;
  align-self: center;
  z-index: 2;
}

/* Motion: new-messages pill entrance (motion-symmetry pass, item 5). `.chat-new-messages-pill-
   enter` is a state-change class chat.js's showNewMessagesPill applies only the moment the pill
   first attaches (never on a later poll that merely updates its count -- see that function's own
   comment), gated the same way item 4's message-entry animation is (chat.mjs's
   isMotionReady/scheduleEnterAnimation). A rise (translateY) + fade, per the brief, distinct from
   item 4's message-entry translateY direction only in magnitude/purpose: this is a standalone
   affordance appearing over the list, not a list item joining it. The playback-controls bundle's
   own connection-health toast (`.toastEnter` keyframes, near `.keyboard-cheatsheet` above) is a
   separate, already-animated element -- this rule intentionally does not touch it. */
body.motion-ready .chat-new-messages-pill {
  transition: opacity var(--motion-base) var(--ease-out), transform var(--motion-base) var(--ease-out);
}

body.motion-ready .chat-new-messages-pill.chat-new-messages-pill-enter {
  opacity: 0;
  transform: translateY(8px);
}

@media (prefers-reduced-motion: reduce) {
  /* Same specificity-matching fix as `.stream-tile`'s own reduced-motion override above -- see
     its comment. */
  body.motion-ready .chat-new-messages-pill {
    transition: none;
  }
}

/* Design-pass 1: new messages pill - PRIMARY BUTTON styling */
.chat-new-messages-pill-button {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  height: var(--pill-h);
  padding: 0 14px;
  border-radius: 999px;
  background: var(--btn-lit-strong), var(--accent-gradient);
  border: 1px solid rgba(232,239,255,.6);
  box-shadow: inset 0 1px 0 rgba(255,255,255,.5), 0 6px 18px rgba(98,199,255,.35);
  font-weight: 600;
  font-size: 12px;
  line-height: 1;
  color: #fff;
  white-space: nowrap;
  cursor: pointer;
}

.chat-new-messages-pill-button:hover,
.chat-new-messages-pill-button:focus-visible {
  /* Hover state - PRIMARY BUTTON brighter */
  background: var(--btn-lit-strong), var(--accent-gradient);
}

/* Kept as its own rule (rather than folded into .chat-messages above) so
   the "no shrink" list stays easy to scan: these are every other flex
   child of .chat-panel besides the message list. */
.chat-status,
.chat-form {
  flex: 0 0 auto;
  padding: 10px 12px 12px;
  border-top: 1px solid var(--edge-soft);
  display: flex;
  flex-direction: column;
  gap: 6px;
}


/* Chat collapse (chat-collapse): a viewer-controlled way to give the chat column's space back to
   video ("theater mode" on desktop - see the `@media (min-width: 901px)` block below - and a
   focus toggle on mobile - see the `@media (max-width: 900px)` block) and take it back again.
   See docs/mvp-user-testing.md's chat-collapse story and docs/backlog.md item 19: chat has been
   "fixed furniture, never squeezed" since that fix, and this is the deliberate, reversible,
   viewer-initiated version of the same reclamation, not a regression of it.

   Two separate buttons, not one that relocates itself, and both created/wired by chat.js (see
   its own comments) rather than server-rendered - `.chat-toggle-collapse` and
   `.chat-toggle-expand` below. They share this base look; the shape-specific rules that follow
   (and the per-breakpoint ones further down this file) are what make each one behave correctly
   in its own state. */
.chat-toggle {
  border: 1px solid var(--glass-edge);
  border-radius: var(--radius-md);
  background: linear-gradient(180deg, rgba(255,255,255,0.16), rgba(255,255,255,0.04));
  color: var(--text);
  font-weight: 700;
  font-size: 0.75rem;
  line-height: 1.2;
  cursor: pointer;
  padding: 0.35rem 0.6rem;
}

.chat-toggle:hover,
.chat-toggle:focus-visible {
  background: linear-gradient(180deg, rgba(255,255,255,0.28), rgba(255,255,255,0.1));
}

/* Absolutely positioned against `.chat-column` (its nearest positioned ancestor - see that
   rule's own `position: relative`), NOT nested inside `.chat-panel`'s flex column: an ordinary
   in-flow child here would cost `.chat-panel` real height, taken straight out of
   `#chat-messages` (the one region that grows/shrinks - see its own comment), in the *expanded*
   state - exactly the state scripts/measure-room-layout.mjs's baseline protects (docs/backlog.md
   item 19). An absolutely positioned box contributes nothing to its containing block's size, so
   this button is free without shrinking the message list by a single pixel at any viewport,
   including 390x844. Only ever visible while `.chat-panel` is not `[hidden]` - chat.js hides the
   two together. */
/* Item 12 (motion-symmetry pass, inset/gap audit): this button's corner offset used to be its
   own bespoke 0.6rem, the one room corner control that did not already match the 0.75rem every
   other one (Live badge, host label, go-live button, grip, video-stats toggle/panel, live-edge
   pill, scrubber, PiP toggle, `.chat-panel`'s own desktop inset) agreed on -- normalized to the
   shared `--inset-chrome` token here, a small (~2.4px) deliberate nudge, not a value-preserving
   substitution like the rest of that list (see :root's own comment on this token). That nudge is
   what exposed the pre-existing bug fixed below: `--inset-chrome` (0.75rem) is `.chat-panel`'s
   own *outer* margin from `.chat-column`'s edge at the desktop breakpoint (see that rule's own
   comment further down), never the *inner* padding its heading sits behind - measuring this
   button from the same token as that outer margin put its right edge flush with `.chat-panel`'s
   own border on desktop (0px inset from the panel's edge, not the padded content column the h2
   reads from), which sits inside `--radius-lg`'s rounded top-right corner arc rather than clear
   of it - visually crossing the panel's own border. chat-toggle-alignment (this comment's fix):
   - `right` now reads `--chat-panel-inset` (the panel's own padding, named as a token above
     specifically for this) instead of `--inset-chrome`, landing the button's right edge in the
     same column the heading's text already stops at, comfortably past the corner radius on both
     breakpoints (the desktop override below adds the panel's own *outer* `--inset-chrome` margin
     back on top, since only that breakpoint's `.chat-panel` carries one).
   - `top` centers the button on the `h2`'s line box rather than merely clearing the padding above
     it, per this calc: `<content top> - (<button box height> - <h2 box height>) / 2`. Content top
     is `1px + --chat-panel-inset`, not `--chat-panel-inset` alone: `.aero-panel`'s own `border: 1px
     solid` (this class is also on `#chat-panel`) sits between the box `getBoundingClientRect()`
     reports and where the padding - and therefore the `h2` - actually starts, so leaving it out
     would land the button 1px above where this calc otherwise says it should be. `2.55rem` is the
     base `button`/`.aero-button` rule's `min-height` (this button's content is shorter, so that
     floor is what actually renders); `1.1rem * 1.3` is the h2's own font-size times its now-
     explicit `line-height` (see that rule's own comment on why "normal" could not be used here).
     Halving the difference and subtracting it from the content top is what makes the button's
     vertical center land on the h2's, to the same sub-pixel precision the browser resolves both
     boxes with - not an approximation tuned by eye. */
.chat-toggle-collapse {
  position: absolute;
  top: 14px;
  right: 16px;
  z-index: 3;
  height: var(--pill-h);
  padding: 0 13px;
  border-radius: 999px;
  /* Design-pass 1: GLASS BUTTON styling */
  background: var(--btn-lit), rgba(42,54,112,.62);
  border: 1px solid var(--edge-lit);
  box-shadow: inset 0 1px 0 rgba(255,255,255,.45), 0 2px 8px rgba(0,0,0,.35);
  font-weight: 600;
  font-size: 12px;
  line-height: 1;
  color: var(--text);
}

/* Rendered only while `.chat-panel` is `[hidden]` (chat.js sets both together) - an ordinary,
   in-flow block, not an overlay like `.chat-toggle-collapse` above. The collapsed state has no
   protected height/width budget the way the expanded one does (there is nothing left inside
   `.chat-column` for this to compete with once the panel is hidden), so it is free to take up
   real space and read as a clear, discoverable control rather than a small corner overlay - see
   the per-breakpoint rules below for how much space and what shape. */
.chat-toggle-expand {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  min-height: 2.75rem;
  gap: 0.4rem;
}
/* Reuses the admin console's collapsed-by-default `.action-row` pattern
   (see that rule's own comment) for the same reason: keep the chat
   column's footprint to one line until a host actually needs it. Must be
   `flex: 0 0 auto` like every other fixed-size child of `.chat-panel`
   (h2, .chat-notice, .chat-status, .chat-form) - #chat-messages is the
   only child allowed to grow or shrink; see the long comment above it in
   room.html and docs/backlog.md item 19 for why that invariant exists. */
.chat-moderation {
  flex: 0 0 auto;
}

.chat-mute-form {
  display: flex;
  align-items: flex-end;
  gap: 0.6rem;
}

.chat-mute-form label {
  flex: 1 1 auto;
  min-width: 0;
}

.chat-muted-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: 0.4rem;
}

.chat-muted-item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem;
  font-size: 0.85rem;
  color: var(--muted);
}
/* One line of author, timestamp and text that wraps, rather than a
   full-width block per message - what makes a sidebar-width column usable
   for a conversation. See specs/room-view-presentation/spec.md ("Messages
   are compact"). display:flex + flex-wrap here already produced this
   layout; the padding/gap below are just tightened to match the narrower
   column. */
.chat-message {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 8px;
  padding: 10px 12px;
  border: 1px solid rgba(193,213,255,.14);
  border-radius: var(--radius-card);
  background: rgba(255,255,255,.055);
}

/* Motion: new chat message entry (motion-symmetry pass, item 4). `.chat-message-enter` is a
   state-change class chat.mjs's renderMessages applies only to a genuinely NEW message (a poll
   that adds to the conversation, never the initial list -- see its own
   `options.animateNewEntries` comment) and only once `document.body` already carries
   `.motion-ready`. Enter timing (`--motion-base`/`--ease-out`) per Part A rule 2; a small
   translateY, not a scale, reads as a message "arriving from below" rather than "growing in
   place", matching how new content actually enters at the bottom of a scrolling list. */
body.motion-ready .chat-message {
  transition: opacity var(--motion-base) var(--ease-out), transform var(--motion-base) var(--ease-out);
}

body.motion-ready .chat-message.chat-message-enter {
  opacity: 0;
  transform: translateY(4px);
}

@media (prefers-reduced-motion: reduce) {
  /* Same specificity-matching fix as `.stream-tile`'s own reduced-motion override above -- see
     its comment. */
  body.motion-ready .chat-message {
    transition: none;
  }
}

.chat-message-author {
  font-weight: 700;
  font-size: 14px;
  line-height: 1.3;
  color: var(--text);
}

.chat-message-verified .chat-message-author {
  color: var(--accent-2);
}

.chat-message-badge {
  font-weight: 700;
  font-size: 9px;
  line-height: 1;
  letter-spacing: .1em;
  text-transform: uppercase;
  padding: 3px 6px;
  border-radius: 999px;
  border: 1px solid rgba(98,199,255,.5);
  color: var(--accent-2);
  background: none;
}

.chat-message-body {
  font-size: 14px;
  line-height: 1.45;
  word-break: break-word;
}

.chat-message-timestamp {
  font-size: 12px;
  color: var(--dim);
  font-variant-numeric: tabular-nums;
}

/* The chat input and the emoji trigger sit side by side; the label (and
   its input) take the remaining width so the fixed-size trigger never
   crowds the message box. min-width:0 lets the input shrink below its
   intrinsic size on narrow screens instead of overflowing. */
.chat-input-row {
  display: flex;
  align-items: flex-end;
  gap: 8px;
}

.chat-input-row label {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 6px;
  font-weight: 600;
  font-size: 12px;
  line-height: 1;
  color: var(--muted);
}

.chat-input-row input {
  height: 44px;
  padding: 0 14px;
  border-radius: var(--radius-card);
  background: var(--ink-soft);
  border: 1px solid rgba(193,213,255,.28);
  box-shadow: inset 0 2px 6px rgba(0,0,0,.35);
  font: inherit;
  font-size: 14px;
  color: var(--text);
}

.chat-input-row input:focus-visible {
  border-color: rgba(98,199,255,.7);
  box-shadow: inset 0 2px 6px rgba(0,0,0,.35), 0 0 0 3px rgba(98,199,255,.25);
  outline: none;
}

/* Design-pass 1: Send button - PRIMARY BUTTON styling */
.chat-form .aero-button.primary {
  height: 44px;
  padding: 0 18px;
  border-radius: var(--radius-card);
  font-weight: 600;
  font-size: 14px;
  line-height: 1;
  background: var(--btn-lit-strong), var(--accent-gradient);
  border: 1px solid rgba(232,239,255,.6);
  box-shadow: inset 0 1px 0 rgba(255,255,255,.5), 0 6px 18px rgba(160,108,255,.35);
  color: #fff;
}

/* The emoji trigger/panel are a progressive enhancement: room.html renders
   #chat-emoji empty, and chat.js creates the trigger and the panel inside
   it once it runs. With JavaScript unavailable there is no button here at
   all — not a dead one that opens nothing — so the message input and Send
   button are entirely unaffected. It is the positioning anchor for the
   panel below, hence position:relative. See
   docs/comet/changes/chat-emoji-picker-panel/specs/chat-message-presentation/spec.md. */
.chat-emoji {
  position: relative;
  flex: 0 0 auto;
}

/* Design-pass 1: emoji trigger - GLASS BUTTON styling */
.chat-emoji-trigger {
  width: 44px;
  height: 44px;
  padding: 0;
  border-radius: var(--radius-card);
  background: var(--btn-lit), rgba(42,54,112,.62);
  border: 1px solid var(--edge-lit);
  box-shadow: inset 0 1px 0 rgba(255,255,255,.45);
  font-size: 1.3rem;
  line-height: 1;
  color: #ffd36a;
  display: flex;
  align-items: center;
  justify-content: center;
}

.chat-emoji-trigger:hover {
  transform: none;
  filter: none;
  border-color: rgba(111, 210, 255, 0.6);
  background:
    linear-gradient(180deg, rgba(255,255,255,0.16), rgba(255,255,255,0.05)),
    rgba(5, 9, 24, 0.58);
}

.chat-emoji-trigger:active {
  transform: none;
  box-shadow: inset 0 2px 6px rgba(0,0,0,0.5);
}

.chat-emoji-trigger:focus-visible {
  outline: 2px solid var(--accent-2);
  outline-offset: 2px;
}

.chat-emoji-trigger[aria-expanded="true"] {
  border-color: rgba(111, 210, 255, 0.6);
  box-shadow: inset 0 2px 6px rgba(0,0,0,0.5);
}

/* The panel opens upward from the trigger, never downward: downward would
   put it directly over the message box and the Send button beneath it,
   which the picker exists to keep clear of (people pick an emoji in order
   to keep typing). Opening upward instead overlaps the conversation above,
   which is an acceptable trade a permanent strip never had to make. Width
   is capped relative to the viewport, not fixed, so it cannot force
   horizontal scrolling on a narrow screen. */
.chat-emoji-panel {
  position: absolute;
  right: 0;
  bottom: calc(100% + 0.5rem);
  z-index: 20;
  display: flex;
  flex-wrap: wrap;
  gap: 0.35rem;
  width: max-content;
  max-width: min(15.5rem, calc(100vw - 2.4rem));
  padding: 0.6rem;
  border-radius: var(--radius-md);
  border: 1px solid rgba(196, 214, 255, 0.28);
  background:
    linear-gradient(180deg, rgba(255,255,255,0.09), rgba(255,255,255,0.02)),
    rgba(6, 10, 26, 0.94);
  box-shadow: 0 12px 28px rgba(0,0,0,0.5), inset 0 1px rgba(255,255,255,0.08);
}

.chat-emoji-panel[hidden] {
  display: none;
}

.chat-emoji-button {
  min-height: 2.1rem;
  width: 2.1rem;
  padding: 0;
  font-size: 1.15rem;
  line-height: 1;
  border-radius: var(--radius-md);
  border: 1px solid rgba(196, 214, 255, 0.28);
  background:
    linear-gradient(180deg, rgba(255,255,255,0.1), rgba(255,255,255,0.035)),
    rgba(5, 9, 24, 0.58);
  box-shadow: inset 0 1px 3px rgba(0,0,0,0.45);
  text-shadow: none;
}

.chat-emoji-button:hover {
  transform: none;
  filter: none;
  border-color: rgba(111, 210, 255, 0.6);
  background:
    linear-gradient(180deg, rgba(255,255,255,0.16), rgba(255,255,255,0.05)),
    rgba(5, 9, 24, 0.58);
}

.chat-emoji-button:active {
  transform: none;
  box-shadow: inset 0 2px 6px rgba(0,0,0,0.5);
}

.chat-emoji-button:focus-visible {
  outline: 2px solid var(--accent-2);
  outline-offset: 2px;
}

.chat-status {
  min-height: 1.2rem;
  margin: 0;
  color: var(--muted);
  font-size: 0.85rem;
}

.danger-card { border-color: rgba(255, 101, 138, 0.28); }

/* admin-console-redesign (AGENTS.md): a card grouping several actions that
   all act on one already-existing thing ("Manage an existing room",
   "Webhook", "Account credentials & access", the two "Danger zone" cards) —
   the replacement for one full `.dashboard-card` per verb. Each action is
   an `.action-row` — a native `<details>` collapsed to just its name by
   default (see below) — separated by a divider line (not around the
   first), reading as "these are related but distinct actions" without
   either the weight of separate card chrome per action or, more
   importantly, the height of every action's fields staying on screen
   whether or not the admin is using it: an early, always-expanded version
   of this same grouping only reduced the Rooms tab's measured height from
   the flat one-card-per-verb layout by a few percent, because grouping
   cards visually does nothing to total height if nothing inside actually
   collapses — see scripts/measure-admin-layout.mjs, whose report is what
   caught that and is why every action below is collapsed by default
   instead. */
.action-group {
  display: grid;
}

.action-row {
  border-top: 1px solid var(--glass-edge);
}

.action-group > .action-row:first-child {
  border-top: none;
}

/* `<summary>`'s native default (`display: list-item`, which is what draws
   the browser's own disclosure triangle) is overridden the same way
   `.confirm-gate > summary` overrides it below — a custom `::after` marker
   replaces the triangle here specifically because this summary also needs
   `justify-content: space-between` (title on the left, optional
   `.action-row-tag` and the marker pushed to the right), which a
   `list-item` box cannot do. */
.action-row > summary {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.6rem;
  padding: 0.85rem 0.15rem;
  cursor: pointer;
  list-style: none;
  color: #eef3ff;
}

.action-row > summary::-webkit-details-marker {
  display: none;
}

.action-row-title {
  font-weight: 750;
}

/* A textual "danger" tag, not color alone, flags a destructive action row
   inside a card that also holds safe ones (color-only distinction is an
   accessibility anti-pattern - see WCAG 1.4.1). The two cards that are
   *wholly* destructive ("Danger zone" - see `.danger-zone` above) skip this
   tag on their own rows: the card's own header already says so, and
   repeating it on every row inside would be exactly the redundant
   restatement this redesign is trying to cut. */
.action-row-tag {
  flex: none;
  font-size: 0.66rem;
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: #ffd7e2;
  border: 1px solid rgba(255, 101, 138, 0.5);
  border-radius: var(--radius-pill);
  padding: 0.15rem 0.55rem;
}

/* The expand/collapse indicator itself: "+" collapsed, "−" open. Part of
   the summary's own flex layout (generated content after the real
   `.action-row-title`/`.action-row-tag` children), which is what lets
   `justify-content: space-between` push it to the row's far right without
   a dedicated wrapper element. */
.action-row > summary::after {
  content: "+";
  flex: none;
  font-weight: 800;
  color: var(--dim);
}

.action-row[open] > summary::after {
  content: "\2212";
}

.action-row-body {
  display: grid;
  gap: 0.75rem;
  padding: 0 0.15rem 1.1rem;
}

/* A bounded-height, internally scrolling list for the three directories
   that can grow without limit as a deployment accumulates rooms, accounts,
   or audit history (Rooms directory, Accounts directory, Audit log) — NOT
   applied to the "Live now" overview's room list, which stays fully
   visible per AGENTS.md's admin-console-redesign brief ("keep it prominent
   and above the fold"), or to anything inside a tab panel's `.action-group`
   (an admin action, unlike a data list, does not grow with usage). Without
   this, the busiest tab's height would depend on how many rooms/accounts/
   audit entries exist, which even a well-optimized action layout cannot
   promise to fit at 1440x900 for every deployment size — see
   scripts/measure-admin-layout.mjs's "typical" vs "empty" scenarios, which
   this rule is what keeps close together regardless of room/user/audit
   count. */
.room-list-scroll {
  max-height: 22rem;
  overflow-y: auto;
}

/* A card wholly dedicated to destructive actions (Remove room, Delete user,
   and the reversible-but-immediately-disruptive Disable room/Disable
   account they sit beside) rather than the borrowed-per-card `.danger-card`
   treatment every individual destructive card used before this redesign —
   see admin.html's comment on confirmation tiers for why toggles and
   deletions share a card here despite different confirm-gate strength.
   Composed with `.danger-card` (for the red border already defined above)
   rather than replacing it, so a mixed card that merely *contains* one
   gated sub-action (Rotate room password inside "Manage an existing room",
   the webhook card) is deliberately NOT given this treatment — only a card
   that is destructive top to bottom earns the strongest visual signal. */
.danger-zone .eyebrow { color: #ffb6c9; }

/* Closed by default: the admin must deliberately open this before the real
   submit button underneath is even reachable, native to <details>/<summary>
   with no JS or CSS required for the mechanism itself (see admin.html's
   top-of-tabs comment for the full confirmation design, including the
   disabled-hidden Enter-key guard every gated form also opens with). This
   block is purely cosmetic: `list-style`/`::-webkit-details-marker` hide
   the default disclosure triangle (the button label's own "…" already
   signals "more required"), `.confirm-gate > summary` reuses
   `.aero-button.danger`'s look via the class already on the element, and
   `.confirm-panel` gives the revealed content a distinct, warm-tinted
   inset so it reads as "you are now inside the dangerous part" rather than
   simply more form. */
.confirm-gate {
  margin-top: 0.1rem;
}

.confirm-gate > summary {
  list-style: none;
  cursor: pointer;
}

.confirm-gate > summary::-webkit-details-marker {
  display: none;
}

.confirm-gate[open] > summary {
  margin-bottom: 0.75rem;
}

.confirm-panel {
  display: grid;
  gap: 0.65rem;
  padding: 0.9rem 1rem;
  border: 1px solid rgba(255, 101, 138, 0.4);
  border-radius: var(--radius-md);
  background: rgba(255, 101, 138, 0.08);
}

.confirm-panel > p.muted {
  margin: 0;
}

/* The strong tier gets a visibly heavier confirm-panel border than the
   moderate/light tiers' shared `.confirm-panel` rule above, so a screenshot
   of the two side by side (or an admin who has seen both before) can tell
   "this one wants more than a click" before reading a word of copy. */
.confirm-gate-strong > .confirm-panel {
  border-color: rgba(255, 101, 138, 0.75);
  box-shadow: inset 0 0 0 1px rgba(255, 101, 138, 0.22);
}

/* The strongest confirmation tier (rotations that invalidate a live
   credential, plus permanent deletions — see admin.html's comment on
   confirmation tiers) additionally requires typing a short word matching
   the action, enforced by the browser's own HTML5 `pattern` constraint
   validation on an unnamed input (never part of the submitted form data —
   see the input's lack of a `name` attribute in admin.html). Monospace and
   widely letter-spaced so the exact word being asked for, and what has
   actually been typed so far, are both unambiguous at a glance. */
.confirm-typed input {
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  letter-spacing: 0.08em;
}

/* Naming the exact target inside a confirmation (admin.js's
   initConfirmMirrors, confirm-mirror.mjs): a JS-only enhancement over text
   that already reads correctly without it (the fallback phrase already
   sitting in this span in the server-rendered markup — "this room", "this
   account", ...). Weight/color echo the destructive button it usually sits
   inside so the mirrored value reads as the one piece of this sentence that
   changes as the admin types, not decoration. */
.confirm-mirror {
  font-weight: 800;
  color: #ffe1ea;
}

/* "Live now" admin overview (admin.html, admin_live.go). Sits above
   .admin-tabs, not inside any tab panel - see admin.html's comment on why.
   Margin-bottom (rather than relying on .full-width's margin-top, which
   only applies inside .dashboard-grid) gives it the same visual gap from
   the tabs below that .full-width cards get from each other. */
.live-overview { margin-bottom: 1rem; }

/* Idle vs. live vs. anomalous must read apart at a glance without needing
   the pill text - "make idle rooms visually quiet and active rooms
   obvious" per the brief. A live room's border/glow uses --success (the
   same color .status-pill.live already uses); an anomalous one
   (stream_status says live but ActiveStreamsForRoom found no resolvable
   host - see admin_live.go) uses --danger at the same strength, since it is
   at least as important to notice as an ordinary failure pill. Idle rooms
   get no glow and slightly reduced opacity so a long room list doesn't
   compete for attention with whatever is actually live; disabled rooms are
   dimmed further still, matching how disabled entries already read
   elsewhere on this page (see the Rooms/Users directories above). */
.live-room-row.is-live {
  border-color: rgba(101, 240, 197, 0.55);
  box-shadow: 0 0 16px rgba(101, 240, 197, 0.16);
}

.live-room-row.is-anomalous {
  border-color: rgba(255, 101, 138, 0.55);
  box-shadow: 0 0 16px rgba(255, 101, 138, 0.16);
}

.live-room-row.is-idle { opacity: 0.72; }

.live-room-row.is-disabled { opacity: 0.5; }

.live-room-meta {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 0.35rem;
  flex-shrink: 0;
}

.live-room-viewers { font-size: 0.8rem; }

/* Quiet neutral pill for an idle room's status - distinct from the bare
   default .status-pill (used elsewhere for e.g. "Webhook off", which keeps
   the brighter default accent dot because that pill is not competing
   against a "live" state on the same row the way this one is). */
.status-pill.idle {
  color: var(--muted);
  box-shadow: none;
}

.status-pill.idle > span:first-child {
  background: var(--dim);
  box-shadow: none;
}

/* Reconciliation-degraded banner - same left-border alert language as
   .gate-error above, scoped to the live overview so it never reads as a
   full-page error. Shown only while a.reconcilerHealth reports a failure
   streak at or past reconcilerFailureEscalationThreshold (reconciler_health.go)
   - the same threshold GET /readyz already treats as not-ready. */
.live-overview-warning {
  margin: 0.75rem 0 0;
  padding: 0.75rem 1rem;
  border-left: 3px solid var(--danger);
  background: rgba(255, 101, 138, 0.1);
  border-radius: 0 var(--radius-md) var(--radius-md) 0;
  color: #ffd9e4;
}

.admin-tabs {
  display: grid;
  gap: 1.1rem;
}

.aero-tablist {
  display: inline-flex;
  gap: 0.3rem;
  max-width: 100%;
  padding: 0.35rem;
  border: 1px solid var(--glass-edge);
  border-radius: var(--radius-lg);
  background:
    linear-gradient(180deg, rgba(255,255,255,0.08), transparent 3rem),
    rgba(6, 10, 26, 0.55);
  box-shadow: inset 0 1px rgba(255,255,255,0.14), inset 0 -1px rgba(0,0,0,0.3);
  overflow-x: auto;
}

.aero-tab {
  min-height: 2.5rem;
  border: 1px solid transparent;
  border-radius: calc(var(--radius-lg) - 0.3rem);
  padding: 0.6rem 1.35rem;
  color: var(--muted);
  background: transparent;
  box-shadow: none;
  text-shadow: none;
  white-space: nowrap;
}

.aero-tab:hover {
  color: var(--text);
  background: rgba(255,255,255,0.08);
  transform: none;
  filter: none;
}

.aero-tab:active {
  transform: none;
  box-shadow: none;
}

.aero-tab:focus-visible {
  outline: 2px solid var(--accent-2);
  outline-offset: 2px;
}

.aero-tab[aria-selected="true"] {
  color: white;
  border-color: rgba(210, 228, 255, 0.42);
  background:
    linear-gradient(180deg, rgba(255,255,255,0.34), rgba(255,255,255,0.06) 48%, rgba(0,0,0,0.18) 49%),
    linear-gradient(135deg, #8256ff, #2477d8);
  box-shadow:
    inset 0 1px rgba(255,255,255,0.55),
    inset 0 -1px rgba(0,0,0,0.32),
    0 6px 16px rgba(31, 85, 212, 0.28);
}

.admin-tab-panel {
  margin-top: 0.2rem;
}

/* Motion: admin tab switch crossfade (motion-symmetry pass, item 7). `.admin-tab-panel-enter` is
   a state-change class admin.js's activate() applies only from an explicit tab switch (click/
   keydown/hashchange), never from its own initial-load call -- see that function's own comment.
   Not gated behind `body.motion-ready` the way the room page's animations are: that class is set
   by room.js only (Part A), which admin.html never loads, and this animation has no first-paint
   race to guard against in the first place -- admin.js's own `animate` flag already guarantees it
   can only ever fire from a later, explicit user action, never from the initial page load activate()
   call. Opacity only, per the brief; the `hidden` attribute (unchanged by this pass) stays the
   sole authority on which panel actually renders/is reachable, including for
   admin-tab-preload.js's no-flash contract and the no-JS fallback (every panel visible without
   JS) -- this rule only fades the panel `hidden` already decided should be visible. */
.admin-tab-panel {
  transition: opacity var(--motion-base) var(--ease-out);
}

.admin-tab-panel.admin-tab-panel-enter {
  opacity: 0;
}

@media (prefers-reduced-motion: reduce) {
  .admin-tab-panel {
    transition: none;
  }
}

@media (max-width: 900px) {
  .admin-grid,
  .host-grid,
  .grid-two,
  .grid-four,
  .grid-many {
    grid-template-columns: 1fr;
  }

  /* `.host-grid` moved from an auto-fit column (which collapsed to one
     column on its own, needing no override here) to the explicit
     two-column template above - see that rule's comment. Explicit columns
     do not collapse by themselves, so this breakpoint now has to say so:
     without it, a phone would still be asked to fit a >=18rem sidebar
     column beside the rooms column. The two-column reasoning does not
     apply below this width anyway - there is no spare horizontal real
     estate to give a sidebar on a 390px screen, which is the entire
     premise `.host-grid`'s comment argues from at desktop widths. */

  .connection-row {
    grid-template-columns: 1fr;
  }

  /* On a narrow stacked column, a long value (the OBS URL - see
     host.html) already wraps across several lines; pinning the Copy
     button to the top of that block (the desktop `flex-start` alignment -
     see `.copy-field`'s own comment) reads as detached from the value it
     copies once there are three or four lines below it. Stacking the
     button under the full value instead, and letting it stretch to match
     the value's width, keeps the button visually attached to what it
     copies and matches the mobile convention this stylesheet already uses
     elsewhere (buttons go full-width and drop below their label on a
     phone - see the generic `button { width: 100% }` a few rules down). */
  .copy-field {
    flex-direction: column;
    align-items: stretch;
  }

  .aero-titlebar,
  .card-header.horizontal,
  .room-row {
    align-items: stretch;
    flex-direction: column;
  }

  /* Room titlebar's mobile-specific compaction used to live here. It is
     now folded into the room page's single, unconditional titlebar
     treatment in the room-chrome section further down this file (position:
     absolute, floating over the video at every viewport, not just this
     breakpoint - see that section for the full "why" and
     docs/comet/changes/theater-as-default/brief.md), so there is nothing
     room-titlebar-specific left to override at this breakpoint. */

  .aero-button,
  button { width: 100%; }

  .chat-emoji-button { width: 2.1rem; }
  .chat-emoji-trigger { width: 2.75rem; }
  /* No mobile override of `.chat-emoji-panel`'s `max-width` here (there used
     to be one, `calc(100vw - 2.4rem)`, which widened the panel instead of
     narrowing it): the panel is anchored via `right: 0` to `.chat-emoji`,
     which is itself already inset from the viewport's right edge by the
     shell, window, content and chat-panel padding stacked between them -
     on a mobile-room-fit measurement at 390px this inset alone is ~50px, so
     a max-width computed from the full viewport width overshoots past the
     *left* edge of the screen instead of being constrained by it. The
     unconditional rule's own `min(15.5rem, calc(100vw - 2.4rem))` already
     keeps the panel comfortably narrower than that inset on every viewport
     this breakpoint covers, without needing to know the inset's exact size. */

  /* Undoes the generic `button { width: 100% }` above for this one: it is a small,
     absolutely-positioned corner pill (see `.go-live-button`), not a full-width action button --
     100% width combined with `right` but no `left` set would stretch it out from near the tile's
     left edge, covering the host label and stats toggle it must stay clear of. */
  .go-live-button { width: auto; }

  /* Same reasoning as `.go-live-button` immediately above: `.stream-tile-retry` sizes to its
     own padding/font within the centered `.stream-tile-interrupted` panel, not the full tile
     width the generic `button { width: 100% }` rule would otherwise impose. */
  .stream-tile-retry { width: auto; }

  /* Same reasoning again, the video-stats overlay's turn (video-stats-overlay): both are real
     `<button>`s that would otherwise stretch to the tile's full width under the generic mobile
     rule -- `.stream-tile-stats-toggle` is a small corner pill like `.go-live-button` above, and
     `.stream-tile-stats-copy` sizes to its own padding within the (already width-constrained)
     panel, not the panel's full width. */
  .stream-tile-stats-toggle { width: auto; }
  .stream-tile-stats-copy { width: auto; }

  /* DEFECT FOUND AND FIXED: this exact `.go-live-button`/`.stream-tile-retry`/`.stream-tile-stats-*`
     pattern above was not extended to the playback-controls bundle's own new per-tile buttons when
     they were first added -- confirmed directly by running scripts/measure-room-layout.mjs's
     390x844 screenshot and inspecting `.live-edge-pill`'s real `getBoundingClientRect()`: with no
     override, the generic `button { width: 100% }` rule above stretched it to the tile's full
     390px width while its CSS still carried `right: 3rem` and no `left`, pushing its (now
     off-screen) left edge to x=-48px -- the pill was still technically present and painted, just
     mostly off the left side of the viewport. Same reasoning as every rule immediately above:
     each of these is a small, absolutely-positioned corner control (or a compact button inside an
     already width-constrained panel/toast), never meant to fill the full mobile width. */
  .live-edge-pill { width: auto; }
  .stream-tile-pip-toggle { width: auto; }
  .keyboard-cheatsheet-close { width: auto; }
  .connection-health-toast-dismiss { width: auto; }

  /* Self-view mode (self-view-mode.mjs): both buttons are `.aero-button` and would otherwise be
     stretched full-width by the generic mobile rule above, exactly like every other corner pill
     in this list -- same reasoning, restated here rather than left as a silent gap. */
  .self-view-toggle { width: auto; }

  /* The own-tile "Shrink to frame" button (`.stream-tile .self-view-toggle`'s unconditional rule
     above) joins the top-right row as its own next member on desktop, where the row has plenty of
     width to spare. At 390px that row is already tight with three members (its own comment); a
     fourth with a longer label ("Shrink to frame" vs. "PiP") would not reliably clear the tile's
     left edge. Rather than guess at a narrower label CSS cannot substitute in, this member drops
     onto its own line directly below the row instead -- still the same top-right corner, still
     clear of the top-center grip and the top-left stats toggle/host label, just a second band
     `2rem` below the first (comfortably past the row's own ~1.3-1.6rem content height, per its own
     reserved-width comments). Confirmed with scripts/measure-room-layout.mjs's own rect-overlap
     probes at 390x844 -- see that harness's self-view checks. */
  .stream-tile .self-view-toggle {
    /* Second band under the chrome row (design pass 2: 26px pills, 8px gap). */
    top: calc(var(--tile-chrome-top) + 26px + 8px);
    right: var(--inset-chrome);
  }

  /* Tighter panel footprint on a small phone tile: `.stream-grid:not(.arranged-focus-rail) >
     .stream-tile { min-height: 8rem }` below is the smallest a tile can be at this breakpoint.
     `.arranged-rail .stream-tile-grip`'s own mobile shrink above follows the same "same concepts,
     tuned per device" pattern.

     THIS RULE PREVIOUSLY ANCHORED FROM THE BOTTOM ONLY (`bottom: 2.6rem; max-height: 6rem`), which
     was the wrong anchor entirely and, empirically, wrong by a real, non-trivial margin: `bottom`
     measures up from the tile's *bottom* edge, but the thing this panel actually needs to clear --
     `#room-titlebar`, `position: absolute; z-index: 10` (above this panel's own z-index: 4
     deliberately; see the toggle's own comment above) -- sits at the tile's *top* edge, and the
     mobile breakpoint immediately above forces it into a column layout (title, then its whole meta
     pill cluster) that wraps to two or three rows on a narrow phone. A bottom-only anchor has no
     relationship to that top-edge quantity at all; getting it right required knowing the tile's
     *total* height too, and a short tile (this probe's own 1-stream/390x844 fixture renders a
     219px-tall tile) leaves the titlebar's real footprint eating more than half of it. Running
     scripts/measure-room-layout.mjs's stats-overlay probe with real `getBoundingClientRect()`
     instrumentation (see that probe's own comment) measured `#room-titlebar`'s actual rendered
     rect at exactly this viewport/content: `{top: 0, bottom: 112}` -- a 112px-tall, 3-row titlebar
     (title, then the full 5-item meta cluster, then "Log out" wrapping to its own third row) against
     the old panel's `top` of 82px (implied by `bottom: 2.6rem` + its then-`max-height: 6rem` against
     a 219px tile) -- a genuine 30px overlap, not a rounding error, which is exactly what the
     screenshot showed: the SOURCE row's pill sitting almost entirely underneath the titlebar's own
     wrapped pill row.

     The fix anchors from the *top* instead, the same `calc(var(--room-titlebar-height) + 0.75rem)`
     idiom `.stream-tile-label` and the desktop `.chat-panel` rule already use to clear this same
     floating titlebar (see either rule's own comment) -- one shared constant, not a second
     independently-tuned number that can drift out of sync with it. `--room-titlebar-height` itself
     (`.room-window`, 2.75rem) is deliberately left untouched here rather than overridden for this
     breakpoint: it is also `.room-titlebar`'s own `min-height`, and bumping it would make the
     titlebar's glass panel taller everywhere below 900px even on a shorter room name/viewer-count
     that does not actually wrap to three rows, not just here where it already does. So this rule
     adds the empirically-measured extra clearance (112px real height minus the shared 44px base =
     68px = 4.25rem) directly in its own `top`, on top of the shared constant: `--room-titlebar-
     height` still keeps `.room-titlebar` and every other consumer in sync with the single-row case,
     and this panel is the one place carrying the "three-row wrap" cost as an explicit, commented
     addend, not a silent, disconnected guess.

     `bottom: 3.1rem` reuses the desktop unconditional rule's own value verbatim (rather than the
     old, mobile-only `2.6rem`) -- it is not actually a viewport-dependent quantity at all. At the
     time this was written it was a clearance from the toggle button's fixed footprint (`bottom:
     0.75rem` plus its own ~1.6rem content height), which did not change between desktop and
     mobile; tile-chrome-fix item 2 has since moved the toggle itself to the tile's top-left corner
     (see its own comment above) while deliberately leaving this panel's bottom-right anchor
     untouched, so `3.1rem` is now simply this panel's own fixed clearance from the tile's bottom
     edge, no longer literally "the toggle's footprint" -- the number did not need to change
     (nothing about the panel's own footprint moved), only what it is a clearance *from*. Setting
     both `top` and `bottom` on an absolutely positioned box with `height: auto` (never touched at
     this breakpoint) lets the browser *compute* the panel's height from the real gap between "just
     below the titlebar" and this fixed bottom clearance -- a real computed relationship, not a
     hand-picked `max-height` guess -- rather than an independent cap that has to be re-guessed by
     hand if the titlebar's own wrap behaviour ever changes again. The unconditional
     `max-height: 21rem` above still applies as an upper bound (harmless here since the computed
     gap is far smaller), and `overflow-y: auto` (unconditional rule) is what makes the remaining
     rows one scroll away, same as ever. scripts/measure-room-layout.mjs's stats-overlay probe now
     asserts this geometrically on
     every run (`.stream-tile-stats-panel`'s rect must not overlap `#room-titlebar`'s, and the SOURCE
     row's own center point must hit-test back to the panel, not the titlebar) rather than only
     checking that its text content is well-formed -- see that probe's own comment for why a
     DOM-presence check alone already missed this once.

     `width` (tile-chrome-round2 item 2: widened from a mobile-only `13rem` to the same `15.5rem`
     cap the unconditional/desktop rule above already uses) is the one value this rule tunes for a
     reason unrelated to the titlebar math above: adding the draggable header
     (`.stream-tile-stats-header`) as this panel's first flex child pushed every field row down by
     that header's own height, and at the old `13rem` width the SOURCE row's value text
     ("1920x1080 · Alice", say) no longer fit on one line -- wrapping it to two lines added another
     ~15px on top of the header's own, which together pushed the row's bottom edge past this
     computed box's own bottom edge (`top`/`bottom` above, ~46px tall on this probe's 219px-tall
     mobile fixture) and out of the panel's `overflow-y: auto` clip, so `elementFromPoint` at the
     row's center hit the `<video>` behind the panel instead of the row itself -- found by actually
     running scripts/measure-room-layout.mjs's stats-overlay probe, not by reasoning about the CSS
     alone (its own `sourceRowReachable` check is exactly what caught this). Widening to `15.5rem`
     keeps the SOURCE row on one line again, which alone closes the gap; the `top`/`bottom`
     clearances immediately above are deliberately UNTOUCHED by this fix -- both are tuned against
     real, hard-earned measurements of the titlebar (top) and the tile's own bottom edge (bottom),
     and reclaiming their place via that vertical math instead would have re-risked the exact
     titlebar-occlusion and video-controls-collision defects those numbers already exist to avoid.
     `calc(100% - 1rem)` (unchanged) still caps this against a tile narrower than `15.5rem + 1rem`
     ever gets, the same as before. */
  .stream-tile-stats-panel {
    right: 0.5rem;
    /* Design pass 2 (mobile): derived from --tile-chrome-top - the mobile titlebar is one 44px line
       now (see the mobile titlebar rules further down), so the wrapped-titlebar arithmetic the
       comment above walks through is history; the row sits 8px under the bar on the first tile. */
    top: calc(var(--tile-chrome-top) + 26px + 8px);
    bottom: 3.1rem;
    width: min(15.5rem, calc(100% - 1rem));
  }

  /* DEFECT FOUND AND FIXED while landing tile-chrome-fix item 2 (moving `.stream-tile-stats-toggle`
     off the tile's bottom-right corner, where it collided with the native `<video controls>` bar):
     the unconditional rule's `top: var(--inset-chrome)` is safe on desktop, where `.stream-tile`
     itself sits well below `#room-titlebar` (the titlebar is a floating overlay only there, not an
     in-flow sibling the tile has to clear), but on a narrow phone the titlebar floats directly over
     the tile's own top edge (see `.stream-tile-label`'s own comment on this same "floats over the
     video at every viewport" behaviour), and this fixture's mobile titlebar wraps to the same
     3-row, ~112px-tall footprint the `.stream-tile-stats-panel` override immediately above already
     had to account for. Found by actually running scripts/measure-room-layout.mjs's own
     stats-overlay probe against this page, not by reasoning about the CSS alone: at 390x844 the
     unconditional `top: var(--inset-chrome)` landed the toggle at y=12-38, squarely inside the
     titlebar's own `<h1>` hit-box (measured y=5.6-28.6, x=16-374 -- nearly the full tile width),
     which made Playwright's real click on the toggle fail outright with "`<h1>...</h1>` ... subtree
     intercepts pointer events" - not a cosmetic overlap, a genuinely unclickable control. Reuses
     the exact `calc(var(--room-titlebar-height) + 4.25rem + 0.75rem)` clearance the stats panel's
     own mobile rule above already measured and proved correct against this same titlebar, rather
     than re-deriving a second, independently-tuned number -- `left` is left as the unconditional
     rule's own `var(--inset-chrome)` (unchanged), so this sits on the opposite side of the tile
     from the panel's `right: 0.5rem`, well below the host label (`.stream-tile-label`'s own mobile
     rect bottom, measured ~81px, comfortably clears before this control's ~124px top) and with no
     other top-left occupant at this breakpoint to collide with. */
  .stream-tile-stats-toggle {
    /* Design pass 2 (mobile): derived from --tile-chrome-top - the mobile titlebar is one 44px line
       now (see the mobile titlebar rules further down), so the wrapped-titlebar arithmetic the
       comment above walks through is history; the row sits 8px under the bar on the first tile. */
    top: var(--tile-chrome-top);
  }

  /* DEFECT FOUND AND FIXED while verifying tile-chrome-fix item 1's top-right row (the
     `.go-live-button`/`.stream-tile-pip-toggle`/`.live-edge-pill` row above): fixing those three
     controls' horizontal spacing said nothing about whether the row was actually visible at this
     breakpoint, and it was not. Confirmed directly with a real Playwright rect dump at 390x844,
     not by reasoning about the CSS alone: `#room-titlebar` renders `{top: 0, bottom: 112}` here
     (the same 3-row, ~112px-tall footprint `.stream-tile-stats-toggle`'s own override above was
     fixed for), and the row's unconditional `top: var(--inset-chrome)` placed the whole row
     (e.g. `.live-edge-pill` at `{top: 12, bottom: 52.8}`) entirely inside that span -- not a
     near-miss, fully painted over by the titlebar's own glass panel (z-index 10, above these
     controls' z-index 2) on every mobile pageview, regardless of the row's own internal spacing.
     A rect-intersection check between row members alone (scripts/measure-room-layout.mjs's
     `runChromeClearanceProbe`) cannot catch this class of defect -- it only compares chrome
     elements against each other, never against the titlebar -- which is exactly the "rects don't
     intersect, but does it actually look right" gap this whole item exists to close; a real
     screenshot at this viewport is what actually caught it.

     Same clearance, same reused constant as the stats toggle's own override immediately above,
     applied to the whole row rather than re-deriving a separate number for it. `right` is left
     alone (unchanged from the unconditional rule): the row keeps its own internal spacing and
     simply moves down as a unit, still comfortably above the tile's native `<video controls>`
     bar at the bottom (a 219px-tall mobile tile in this fixture, verified by the same rect dump). */
  .go-live-button,
  .stream-tile-pip-toggle,
  .live-edge-pill {
    /* Design pass 2 (mobile): derived from --tile-chrome-top - the mobile titlebar is one 44px line
       now (see the mobile titlebar rules further down), so the wrapped-titlebar arithmetic the
       comment above walks through is history; the row sits 8px under the bar on the first tile. */
    top: var(--tile-chrome-top);
  }

  /* `.room-fit-toggle` used to need its own mobile `width: auto` override
     here (it was an absolutely positioned corner pill directly on
     `.aero-player-stage`, like `.go-live-button`/`.stream-tile-retry`
     above). It moved into `.room-titlebar-meta` (room.js/room-theater.mjs)
     alongside the viewer-count pill and cinema button - see the room-chrome
     section's comment on `.room-titlebar-meta .aero-button` further down
     this file - and that unconditional rule already sets `width: auto` for
     every button in the cluster at every viewport, so this breakpoint has
     nothing left to undo for it. */

  /* Same reasoning as `.go-live-button`/`.stream-tile-retry` above, chat-collapse's turn:
     `.chat-toggle-collapse` is the other absolutely positioned corner pill
     in this file (`top`/`right` set, no `left`) - without this override it
     would stretch to the full width of `.chat-column`, running directly behind "Room chat" instead
     of sitting beside it as a compact button. `.chat-toggle-expand` is deliberately NOT listed
     here: it is meant to be full width on a narrow viewport (see its own unconditional rule) - the
     generic rule and that one agree, so there is nothing to undo. */
  .chat-toggle-collapse { width: auto; }

  /* Same reasoning again, the new-messages pill's turn: `.chat-new-messages-pill-button` is a
     real `<button>` (see its own unconditional rule above), so without this override the generic
     mobile rule would stretch it to the full width of `.chat-messages` - defeating the sticky
     `<li>` wrapper's `align-self: center` and covering the message list edge-to-edge instead of
     reading as a small floating pill, the exact defect class `.chat-toggle-collapse`/
     `.room-fit-toggle` above already document. */
  .chat-new-messages-pill-button { width: auto; }

  /* Stream arrangement (multi-stream-grid-configuration), narrow-viewport half of "beside it on
     a wide screen, beneath it on a narrow one" (spec, "Focus and rail"): the row of desktop
     becomes a column, focus on top. `.arranged-rail` swaps its fixed *width* for a fixed
     *height* and scrolls sideways instead of vertically -- a horizontal strip of thumbnails
     beneath the focus, the same "same concepts, tuned per device" mobile already applies
     everywhere else in this stylesheet, not a full-height sidebar squeezed into a phone's
     limited width. */
  .stream-grid.arranged-focus-rail {
    flex-direction: column;
  }

  /* Mirrors the real rail's own narrow-viewport override immediately above: the preview overlay
     must match whatever layout is actually about to result, so its row of two zones becomes a
     column here too, and the rail zone's flex-basis shrinks from `.arranged-rail`'s desktop width
     (13rem) to its mobile one (6.5rem, see `.arranged-rail .stream-tile` below). */
  .arrangement-snap-overlay {
    flex-direction: column;
  }

  .snap-zone-rail {
    flex-basis: 6.5rem;
  }

  .arranged-focus {
    flex: 1 1 auto;
    min-height: 0;
  }

  .arranged-rail {
    flex: 0 0 auto;
    flex-direction: row;
    overflow-x: auto;
    overflow-y: hidden;
    padding-right: 0;
    padding-bottom: 0.15rem;
  }

  .arranged-rail .stream-tile {
    flex: 0 0 auto;
    width: 6.5rem;
  }

  /* Same reasoning as `.go-live-button`/`.stream-tile-retry` above: a small, in-flow pill, not
     the generic full-width mobile button. */
  .arrangement-reset-button { width: auto; }

  .aero-tablist .aero-tab { width: auto; flex: 1 1 0; }

  /* Below the breakpoint the two-column room layout becomes one column -
     video, then chat - and the whole room still fits one screen instead of
     scrolling. See docs/comet/changes/mobile-room-fit/specs/
     room-view-presentation/spec.md ("Narrow viewports stack, and still fit
     the screen"). The video column keeps its aspect-ratio sizing (full
     width, still 16:9, unbounded by the desktop-only max-width/max-height
     in the `@media (min-width: 901px)` block below, which does not apply
     here); the chat column gives up its clamped width and the desktop-only
     "take the panel out of flow so it can't grow the row" trick above, in
     favour of its own natural height with the message list scrolling
     internally. `.chat-column`'s `position: relative` is harmless left in
     place (nothing below still needs a containing block from it), but
     `.chat-panel` MUST go back to `position: static` here - otherwise it
     stays absolutely positioned with nothing to stretch it, collapses to
     zero height, and the whole chat column disappears.

     `.room-shell`'s height/`align-items: stretch`, `.room-window`'s flex
     column, `.room-columns`'s `flex: 1 1 auto; min-height: 0`, and
     `.aero-player-stage`'s `flex: 0 0 auto` are now unconditional (see
     those rules above) - room-adaptive-fill made the desktop layout need
     the exact same "real, current height to fit into" chain this
     narrow-viewport layout pioneered, so they moved out of this block
     instead of being duplicated. All that is left here is the one thing
     genuinely specific to stacking: `.room-columns` runs top-to-bottom
     instead of side-by-side, so `.chat-column` needs its own
     `flex-direction: column` and must claim the *full width* (rather than
     `--chat-width`) and the *height* `.aero-player-stage` leaves (rather
     than matching the video's height via stretch, which only makes sense
     side-by-side).

     From there it is still one flex-column chain down to the one region
     that actually absorbs the leftover space: `.room-window`'s two
     children are the header (`flex: 0 0 auto`, natural height) and
     `.room-columns` (`flex: 1 1 auto`, gets the rest); `.room-columns`'s
     two children are `.aero-player-stage` (`flex: 0 0 auto`, its own
     aspect-ratio height at full width - left growing it would compete
     with `.chat-column` for the same leftover space, which is the exact
     "regions competing to shrink" failure this change exists to avoid)
     and `.chat-column` (`flex: 1 1 auto; min-height: 0` here, gets the
     rest); `.chat-column`'s only child `.chat-panel` fills it in turn.
     Inside `.chat-panel`, the heading, status line and composer are
     already `flex: 0 0 auto` and `#chat-messages` already
     `flex: 1 1 auto; min-height: 0;` (both set unconditionally, above) -
     so once this chain hands `.chat-panel` a real height to grow into,
     `#chat-messages` is the single region that finally grows or shrinks
     to fill it, exactly as required.

     None of that held for two or more live streams before the two rules immediately below
     existed - see docs/backlog.md item 19 ("The room layout breaks down above two streams,
     worst on mobile"), measured with scripts/measure-room-layout.mjs. `.grid-two`/
     `.grid-four`/`.grid-many` collapse to a single column at this breakpoint (top of this
     block), so N stacked tiles asked for roughly N times a single tile's height. The
     unconditional `.aero-player-stage` rule above has no `overflow` set and no explicit
     height, so aspect-ratio is only ever "a preferred size, not a hard cap" (that rule's own
     comment) - it lets the box grow to fit taller content, deliberately, so a streamer's
     self-preview tile is never clipped. On a narrow viewport that same allowance let the
     plain equal grid grow without limit: `.aero-player-stage`'s `flex: 0 0 auto` means it
     never shrinks back, so every pixel it grew by came straight out of `.chat-column`
     (`flex: 1 1 auto; min-height: 0` below) - at three streams chat had nothing left to give
     and the composer landed under `.aero-window`'s `overflow: hidden`, unreachable, with the
     page itself not scrolling to reach it (`documentElement.scrollHeight === clientHeight` at
     every count - there was nowhere for the extra height to go but to eat the chat column in
     place). Fixing that takes two rules working together, not one: */
  .aero-player-stage {
    /* Makes the aspect-ratio height authoritative instead of a lower bound - the same
       technique `#chat-messages` already relies on (see its own comment) to stop tall content
       from growing its box instead of scrolling inside it. Scoped to this breakpoint only:
       the desktop rule above is untouched, so a self-preview tile still grows the box there
       exactly as before. This alone is not sufficient - see the `.stream-tile` rule right
       below, which is what stops the grid from "fixing" this by silently crushing every tile
       to a sliver instead of scrolling. `.arranged-rail`'s `overflow-x: auto` above is a
       different, unrelated scrollbar (the horizontal thumbnail rail, only present once a
       viewer has promoted a stream); this one covers the default, un-arranged grid every
       viewer starts in, on its own vertical axis. */
    overflow-y: auto;
  }

  /* `:not(.arranged-focus-rail) > .stream-tile` reaches only a tile that is a *direct* child of
     the plain equal grid - once a viewer promotes a stream, room-grid.mjs nests every tile one
     level deeper inside `.arranged-focus`/`.arranged-rail` (see the "Stream arrangement" block
     above), so this selector does not match a focus or rail tile at all, and the deliberately
     small rail thumbnails (`.arranged-rail .stream-tile { width: 6.5rem }` above) are untouched.
     A rail-scoped exclusion by class was considered instead of this structural one and rejected:
     it would have needed updating in two places (here and there) every time the arrangement
     markup changed shape, where this reaches its target by construction and needs updating in
     neither.

     Without this, capping `.aero-player-stage` above (so it stops growing) just moves the
     damage one level down: CSS Grid's "automatic minimum size" - the floor an `auto` row won't
     shrink below - is defined as zero for any item whose `overflow` is not `visible` (see
     https://www.w3.org/TR/css-grid-1/#min-size-auto), and `.stream-tile` already sets
     `overflow: hidden` (above, for the label/interrupted-overlay clipping). Measured
     directly: with the stage capped but no floor here, five stacked tiles compressed to 17px
     each - technically non-zero and technically "in" the chat-message-visible sense the
     invariant cares about, but useless as video and not what "the video keeps its aspect ratio"
     (specs/room-view-presentation/spec.md, "Narrow viewports stack, and still fit the screen")
     was ever supposed to allow.

     `min-height` is an explicit author size, not the suppressed automatic one, so it applies
     regardless of `overflow: hidden` and gives each row a legible floor. The value is a
     legibility floor, not a viewport-derived size - deliberately not `vh`/`dvh`/aspect-ratio
     the way the stage itself is bounded, the same way `.arranged-rail`'s `13rem`/`6.5rem`
     constants a few rules up are plain sizing decisions rather than fractions of the screen.
     Once total tile height exceeds what `.aero-player-stage` has to give, the excess overflows
     into the scrollbar the rule above just added - reachable by scrolling the video area, not
     lost, and never at the chat column's expense. */
  .stream-grid:not(.arranged-focus-rail) > .stream-tile {
    min-height: 8rem;
  }

  .room-columns {
    flex-direction: column;
  }

  .chat-column {
    display: flex;
    flex-direction: column;
    flex: 1 1 auto;
    min-height: 0;
    width: 100%;
  }

  .chat-panel {
    position: static;
    inset: auto;
    flex: 1 1 auto;
    min-height: 0;
  }

  /* Chat collapse (chat-collapse), narrow-viewport half: a focus toggle between video and chat,
     not a width change - there is no spare width to give up on a 390px screen (the confirmed
     decision this implements). Collapsing shrinks `.chat-column` from its usual
     `flex: 1 1 auto` (above) down to just `.chat-toggle-expand`'s own height
     (`flex: 0 0 auto`, `.chat-panel` is `[hidden]` and contributes nothing), and hands the
     freed *height* to `.aero-player-stage` by switching it from the unconditional, fixed
     `flex: 0 0 auto` (docs/backlog.md item 19's own fix, untouched above) to `flex: 1 1 auto`
     here - the same "exactly one region absorbs leftover space" rule this file already applies
     everywhere else, just handed to a different region while chat is the one giving space up
     instead of the one receiving it. Both selectors carry three classes' worth of specificity
     (`.room-columns.chat-collapsed .chat-column`/`.aero-player-stage`), which beats the plain
     `.chat-column`/`.aero-player-stage` rules above regardless of source order - see
     room_chat_collapse_test.go for why these must stay inside this exact media query block. */
  .room-columns.chat-collapsed .chat-column {
    flex: 0 0 auto;
    min-height: 0;
  }

  .room-columns.chat-collapsed .aero-player-stage {
    flex: 1 1 auto;
    min-height: 0;
  }

  /* Two copies of "Show chat" exist in the DOM at all times (chat.js's ensureChatToggleButtons) -
     this one, created inside `#room-titlebar-meta`, is the desktop control (see this rule's
     desktop-breakpoint counterpart, `.room-columns.chat-collapsed .chat-column`, in the
     `@media (min-width: 901px)` block above). Mobile keeps the in-column bar instead
     (`.chat-toggle-expand`, unconditional rule above): the wrapped mobile titlebar is already
     crowded (`.room-titlebar-meta`'s own comment further down this file measures it at up to five
     items on one wrapped line before this feature even existed) - docs/backlog.md item 30. */
  .room-titlebar-meta .room-chat-expand-toggle {
    display: none;
  }

  /* Design pass 1 (mobile half): on phones the video stage is full-bleed (design 1d/1e) - no
     12px page padding around the row. This also keeps the first tile at its previous height, which
     the mobile stats overlay's fixed top/bottom insets were tuned against. */
  .room-shell .room-columns {
    padding: 0;
  }

  /* Design pass 2 (mobile): tile chrome is one 26px row at a 10px inset. The first tile (the one
     under the 44px floating titlebar) and an arranged focus tile start their row 8px below the
     bar; every other tile keeps the plain inset. --inset-chrome is re-scoped on the tile so the
     label/PiP/LIVE offsets that read it shrink together with the row. */
  .room-shell .stream-tile {
    --inset-chrome: 10px;
  }
  .room-shell .stream-grid > .stream-tile:first-child,
  .room-shell .arranged-focus .stream-tile {
    --tile-chrome-top: calc(var(--room-titlebar-height) + 8px);
  }
  .room-shell .stream-tile-label,
  .room-shell .stream-tile-stats-toggle,
  .room-shell .live-edge-pill,
  .room-shell .stream-tile-pip-toggle,
  .room-shell .stream-tile .self-view-toggle {
    height: 26px;
  }
  .room-shell .stream-tile-label {
    font-size: 12px;
    padding: 0 10px;
  }
  .room-shell .stream-tile-stats-toggle {
    font-size: 11px;
    padding: 0 9px;
  }
  .room-shell .live-edge-pill,
  .room-shell .stream-tile-pip-toggle {
    font-size: 10px;
    padding: 0 9px;
  }
  .room-shell .live-edge-pill {
    padding-left: 8px;
    gap: 5px;
  }
  .room-shell .stream-tile-grip {
    top: calc(var(--tile-chrome-top) + 5px);
  }

  /* Design pass 2 (mobile): the streamer's self-preview frame is thumbnail-sized on a phone, so its
     name pill takes the rail-thumbnail size (22px, 11px type) and stays clear of the self-view
     toggle beneath it - the harness's self-view overlap probe is the gate. */
  .room-shell .self-preview-tile .stream-tile-label {
    height: 22px;
    padding: 0 8px;
    font-size: 11px;
  }

  /* No streams yet (mobile): keep the empty stage its normal 16:9 height with the "Waiting for
     streams…" message centred BELOW the floating titlebar, and - when chat is hidden - do not let
     an empty stage swallow the screen: it stays 16:9 and the "Show chat" bar sits right under it.
     Neither state was measured before v1.23.0 (the phone matrix started at one stream). */
  .room-shell #room-grid.grid-empty {
    /* The id outranks `.room-shell .stream-grid:not(.arranged-focus-rail)`'s `align-content: start`
       below, which would otherwise pin the message to the top edge, under the bar. */
    height: 100%;
    padding-top: var(--room-titlebar-height);
    box-sizing: border-box;
    align-content: center;
    place-items: center;
  }
  .room-shell .room-columns.chat-collapsed .aero-player-stage:has(.stream-grid.grid-empty) {
    flex: 0 0 auto;
    aspect-ratio: 16 / 9;
  }

  /* Design pass 4 (mobile): the stage and chat are flat, full-bleed bands.
     - Un-arranged grid (chat hidden, artboard 1e): tiles stack 8px apart; the first is full-bleed
       with square corners under the titlebar, the rest carry 10px side margins and 12px corners.
     - Focus + rail (chat open with 2+ streams, artboard 1d - room.js's effectiveArrangement picks
       this by default on phones): a full-bleed focus tile over a horizontal rail of 124x70
       thumbnails with 22px name pills; the rail's desktop top padding (titlebar clearance) does
       not apply because the rail sits below the focus tile here.
     - Chat: a flat band with a top hairline instead of a floating glass panel; tighter header,
       list, cards and composer; the "Message" label is kept for assistive tech but not painted.
     - "Show chat" (chat hidden) is a 48px gradient bar with 10px side margins. */
  .room-shell .stream-grid:not(.arranged-focus-rail) {
    /* One column of 16:9 tiles; the stage scrolls (overflow-y below) rather than crushing them. */
    grid-template-columns: minmax(0, 1fr);
    grid-auto-rows: auto;
    align-content: start;
    height: auto;
    gap: 8px;
  }
  .room-shell .stream-grid:not(.arranged-focus-rail) > .stream-tile {
    aspect-ratio: 16 / 9;
    min-height: 0;
    grid-column: auto;
    width: auto;
    justify-self: stretch;
    border-radius: 12px;
    margin: 0 10px;
    box-shadow: 0 0 0 1px rgba(193,213,255,.18);
  }
  /* Focus + rail stacks vertically on a phone, so the stage is as tall as the 16:9 focus tile
     plus the rail - not a 16:9 box the pair has to squeeze into. */
  .room-shell .aero-player-stage:has(.stream-grid.arranged-focus-rail) {
    aspect-ratio: auto;
    height: auto;
  }
  .room-shell .arranged-focus .stream-tile {
    aspect-ratio: 16 / 9;
    width: 100%;
  }
  .room-shell .arranged-rail .stream-tile-grip {
    display: none; /* thumbnails are tapped to promote; the drag grip is a desktop affordance */
  }
  .room-shell .stream-grid:not(.arranged-focus-rail) > .stream-tile:first-child,
  .room-shell .arranged-focus .stream-tile {
    border-radius: 0;
    margin: 0;
    box-shadow: none;
  }
  .room-shell .arranged-rail {
    gap: 8px;
    padding: 8px 10px;
  }
  .room-shell .arranged-rail .stream-tile {
    width: 124px;
    border-radius: 10px;
  }
  .room-shell .arranged-rail .stream-tile-label {
    top: 6px;
    left: 6px;
    height: 22px;
    padding: 0 8px;
    font-size: 11px;
    max-width: calc(100% - 12px);
  }
  .room-shell .chat-panel {
    border-radius: 0;
    border: 0;
    border-top: 1px solid rgba(193,213,255,.18);
    background: rgba(28,36,76,.35);
    box-shadow: none;
    backdrop-filter: none;
  }
  .room-shell .chat-panel h2 {
    font-size: 15px;
    padding: 10px 6.5rem 0 12px;
    min-height: calc(10px + 32px);
  }
  .room-shell .chat-toggle-collapse {
    top: 10px;
    right: 12px;
    height: 32px;
    padding: 0 12px;
  }
  .room-shell .chat-notice {
    font-size: 11.5px;
    line-height: 1.35;
    padding: 0 12px 8px;
  }
  .room-shell .chat-messages {
    padding: 10px 10px 6px;
    gap: 5px;
  }
  .room-shell .chat-message {
    padding: 8px 10px;
    border-radius: 10px;
  }
  .room-shell .chat-message-timestamp {
    font-size: 11.5px;
  }
  .room-shell .chat-form {
    padding: 8px 10px 12px;
  }
  .room-shell .chat-input-row label {
    font-size: 0;
    gap: 0;
  }
  .room-shell .chat-input-row input {
    font-size: 14px;
  }
  .room-shell .chat-form .aero-button.primary {
    padding: 0 14px;
  }
  .room-shell .chat-toggle-expand {
    margin: 0 10px;
    width: auto;
    height: 48px;
    min-height: 0;
    border-radius: 12px;
    background: var(--btn-lit-strong), var(--accent-gradient);
    border: 1px solid rgba(232,239,255,.6);
    box-shadow: inset 0 1px 0 rgba(255,255,255,.5), 0 6px 18px rgba(160,108,255,.35);
    font-size: 14px;
    font-weight: 600;
    color: #fff;
  }

  /* Design pass 1 (mobile half): the floating titlebar is ONE 44px line. The title block is
     capped and ellipsised, and the pill cluster scrolls horizontally behind a right-edge fade
     instead of wrapping to a second or third row over the video (the wrapped bar used to cover
     the first tile's chrome and the stats overlay's top rows; docs/backlog.md item 30). Fill
     frame and Cinema are deliberately absent on phones per the approved design; the "Live grid"
     pill keeps only its glowing dot. Scrollbars are hidden on purpose - the fade is the cue. Selectors carry `.room-shell` so the
     textual guards that look up the unconditional .room-titlebar rule keep finding that one (and
     so no bare-brace text appears inside this block for brace-counting guards). */
  .room-shell .room-titlebar {
    /* The generic narrow-viewport `.aero-titlebar` rule stacks title over actions in a column;
       this bar is one row, always. */
    flex-direction: row;
    align-items: center;
    padding: 0 0 0 12px;
    border-radius: 0;
    gap: 8px;
    overflow: hidden;
  }
  .room-shell .room-titlebar > div:first-child {
    flex: none;
    max-width: 140px;
  }
  .room-shell .room-titlebar .eyebrow {
    font-size: 9px;
  }
  .room-shell .room-titlebar h1 {
    font-size: 14px;
    line-height: 1.35;
    overflow: hidden;
    text-overflow: ellipsis;
  }
  .room-shell .room-titlebar-meta {
    flex: 1 1 auto;
    min-width: 0;
    max-width: none;
    height: var(--room-titlebar-height);
    justify-content: flex-start;
    flex-wrap: nowrap;
    gap: 6px;
    padding: 0 48px 0 6px;
    overflow-x: auto;
    overflow-y: hidden;
    scrollbar-width: none;
  }
  .room-shell .room-titlebar-meta::-webkit-scrollbar {
    display: none;
  }
  .room-shell .room-titlebar-meta > * {
    flex: none;
  }
  .room-shell .room-titlebar-meta .aero-button {
    width: auto;
  }
  .room-shell .room-titlebar-meta .room-fit-toggle,
  .room-shell .room-titlebar-meta .room-cinema-toggle,
  .room-shell .room-titlebar-meta .titlebar-divider {
    display: none;
  }
  .room-shell .room-titlebar .room-titlebar-meta .status-pill.live {
    width: var(--pill-h);
    padding: 0;
    gap: 0;
    justify-content: center;
    font-size: 0;
  }
  .room-shell .room-titlebar .room-titlebar-meta .status-pill.live > span:first-child {
    width: 8px;
    height: 8px;
  }
  .room-shell .room-titlebar::after {
    content: '';
    position: absolute;
    top: 0;
    right: 0;
    bottom: 0;
    width: 36px;
    background: linear-gradient(90deg, transparent, rgba(20,26,58,.95));
    pointer-events: none;
  }
}

/* Desktop/tablet counterpart to the narrow-viewport block above - the
   mirror breakpoint (901px, one pixel past that block's 900px ceiling) so
   the two can never both or neither apply. See specs/room-view-
   presentation/spec.md ("The video is the largest 16:9 box that fits")
   and docs/comet/changes/room-adaptive-fill/brief.md. */
@media (min-width: 901px) {

/* First-row tiles sit under the floating titlebar (`.room-titlebar` is absolutely positioned over
   the top 44px of `.aero-player-stage`, design pass 1), so their chrome row starts below it; a
   second-row tile keeps the plain inset. Desktop only - the narrow-viewport block positions its
   chrome explicitly (see `.stream-tile-stats-toggle` there). */
  .grid-single .stream-tile,
  .grid-two .stream-tile,
  .grid-three .stream-tile:nth-child(-n+2),
  .grid-four .stream-tile:nth-child(-n+2),
  .grid-many .stream-tile:nth-child(-n+2),
  .arranged-focus .stream-tile {
    --tile-chrome-top: calc(var(--room-titlebar-height) + var(--inset-chrome));
  }

  /* The titlebar cluster stays on one line on desktop; the unconditional rule keeps wrap as the
     narrow-viewport safety valve (see .room-titlebar-meta). */
  .room-titlebar-meta {
    flex-wrap: nowrap;
  }
  /* `.room-columns` becomes a size query container so `.aero-player-stage`
     below can read its resolved *height* while computing a *width* -
     ordinary `%` cannot cross axes like that (a `%` inside `width` always
     means a fraction of the container's width, never its height).
     `.room-columns`'s own size never depends on its content (both its
     width, via `.room-window`'s stretch, and its height, via its own
     `flex: 1 1 auto` in that column - both set above - are resolved before
     its children are laid out), so opting it into size containment here
     changes nothing about how it is itself sized. `position: relative` is
     kept as a stable containing-block guarantee even though nothing at
     this breakpoint currently needs it against `.room-columns` itself -
     `.chat-column` is its own containing block (`position: relative`,
     unconditional rule above) for `.chat-panel`'s inset, not this one.
     `justify-content: center` centers the *row's whole content* - the
     video plus the real gap plus the chat column, together, as one group -
     within any leftover space, not the video alone: at a height-bound
     viewport (see `.aero-player-stage` below) the video+gap+chat group is
     narrower than the row, and this is what turns that leftover into a
     symmetric margin on both sides instead of the video sitting flush left
     with a single lopsided gap on the right (room-layout-defects; measured
     at 1999x837, roughly ±27px each side instead of one ~256px hole). See
     `.aero-player-stage`'s own comment below for why chat rejoining the
     row as a real flex track, instead of floating over the video, is the
     change that makes this centering meaningful at all. */
  .room-columns {
    container-type: size;
    position: relative;
    justify-content: center;
  }

  /* Bounds the video on both axes at once instead of deriving height from
     width alone. `align-self: center` opts this one item out of
     `.room-columns`'s `align-items: stretch`: left stretched, this item's
     height would be forced to the row's height regardless of what width
     ends up being, which is the opposite problem to the one below. The
     `width` declaration is deliberately not `auto`: an auto main size on a
     non-growing, non-stretched flex item resolves through flexbox's own
     content-based fallback (`max-content`), not through `aspect-ratio` and
     `max-width`/`max-height` jointly the way a plain box's would - which
     produced a video stuck at a tiny, content-sized box regardless of
     screen size during verification, independent of any live stream.
     Giving `width` an explicit value sidesteps that fallback entirely.

     The first `width` is the width-only bound - the row width *minus*
     `.chat-column`'s own flex-basis (`--chat-width`) and the gap between
     them (`--room-gap`), not simply the full row width (room-layout-
     defects, reversing the formula this rule shipped with under
     theater-as-default). Chat is an ordinary flex track sharing this row
     now (see `.chat-column` below), not an absolutely positioned overlay
     contributing nothing to the row's layout, so there is real sibling
     width to subtract. A percentage on a flex item resolves against the
     flex container's own content-box width regardless of what its siblings
     do (percentages do not "already account for" other flex items), so
     `calc(100% - var(--chat-width) - var(--room-gap))` is exactly "this
     row's width, less what the other flex child and the gap between them
     actually take" - not an approximation, and never out of sync with
     `.chat-column`'s real flex-basis since both read the same custom
     property. This is also what a browser without container query unit
     support (`cqh`) still gets, since an unparseable value in the second
     declaration below leaves this one in effect rather than falling
     through to nothing.

     The second `width`, `min()`-ed against the first, adds the height
     bound: `100cqh` is 100% of the query container's (`.room-columns`'s)
     height, and multiplying by 16/9 converts "the tallest this box could
     be" into "how wide it would need to be to be that tall at 16:9" - so
     `min()` between the two picks whichever bound is tighter. `aspect-
     ratio` (set on the unconditional rule above) then derives `height:
     auto` from this now-definite width in the ordinary, unambiguous,
     single-direction way - width binds on a wide screen, height binds on a
     tall one. This is the same resolution `object-fit: contain` gives an
     `<img>`, arrived at explicitly rather than left to a fallback flexbox
     does not extend to aspect-ratio boxes.

     Any leftover width or height splits evenly via `.room-columns`'s
     `justify-content: center` above (width - across the whole video+gap+
     chat row as one group, so a height-bound video's leftover space is a
     symmetric margin rather than a lopsided gap beside a chat panel pinned
     to the row's right edge) and this rule's own vertical centering within
     the stretched row (height), rather than pinning the video to a corner.

     Why subtract chat's width instead of keeping the previous full-row
     formula: measurement found the "wider" full-row stage was actually
     hiding 12-25% of the rendered picture behind the floating chat panel
     at common desktop sizes (1280x800 through 1999x837) - a bigger nominal
     container is not a bigger visible picture when something opaque sits
     on top of a slice of it. On a height-bound viewport (e.g. 1999x837)
     this subtraction changes nothing about the video's own width - it was
     already narrower than the row and stays exactly that wide - it only
     changes where the row's leftover space goes (evenly split, not
     entirely behind the chat panel). On a width-bound viewport (1280x800,
     1440x900) the video does get narrower than it was, because it is now
     genuinely sized to the space actually left over once chat's real
     column is honoured, rather than a width that only looked available
     because chat was floating on top of it. */
  .aero-player-stage {
    /* Design pass 1: the stage sits at the top of the row (the titlebar rides its top edge), so a
       width-bound viewport's spare height collects below the picture, not around it. */
    align-self: flex-start;
    height: auto;
    width: calc(100% - var(--chat-width) - var(--room-gap));
    width: min(calc(100% - var(--chat-width) - var(--room-gap)), calc(100cqh * 16 / 9));
  }

  /* Two columns instead of the base `.check-list`'s single column, once
     there is real desktop width to spend on it: five short items in one
     column (the mobile/narrow layout, left as-is below this breakpoint)
     cost roughly twice the vertical space five items in two columns do.
     That space is exactly what the "stop making hosts scroll" brief is
     spent on - see the `.host-grid`/`.connection-list` comments above for
     the rest of the same budget. Scoped to `.obs-checklist` specifically,
     not `.check-list` itself, so nothing else that reuses the checklist
     visual (there is nothing else today, but the class is generic) is
     silently reflowed by a rule written for this one card. */
  .obs-checklist {
    grid-template-columns: repeat(2, minmax(0, 1fr));
    column-gap: 1.25rem;
  }

  /* Matches `.room-titlebar-meta .aero-button`'s own shrink further down
     this file (same numbers, deliberately) - the admin/host titlebar is
     ~44px now too (see `.aero-titlebar`'s comment above,
     docs/comet/changes/theater-as-default/brief.md), and the base
     `.aero-button`/`button` rule's `min-height: 2.55rem` plus its own
     padding would push this bar taller than that on its own. Scoped to
     `@media (min-width: 901px)` because below it `.aero-titlebar` becomes a
     stacked column (see the narrow-viewport media query above) and its
     buttons intentionally go full-width there, same as every other button
     on a phone - shrinking them here as well would fight that, for no
     benefit on a screen already tall enough to spend on a stacked header. */
  .titlebar-actions .aero-button {
    min-height: 1.9rem;
    padding: 0.3rem 0.75rem;
    font-size: 0.78rem;
  }

  /* Chat rejoins the row as a real flex track here (room-layout-defects) -
     the unconditional `.chat-column` rule above (`flex: 0 0 var(--chat-
     width)`) already does exactly that, so this breakpoint no longer needs
     to override its positioning at all. It used to dock `.chat-column`
     itself as an absolutely positioned overlay over the video's edge (see
     git history / docs/comet/changes/theater-as-default's own spec text at
     the time); measurement found that "wider-looking" full-bleed video was
     actually hiding 12-25% of the picture behind the panel at common
     desktop sizes; see `.aero-player-stage`'s comment above for the full
     reasoning and specs/room-view-presentation/spec.md's "Layout" section
     ("bounded ... by the width available beside the chat column"), which
     this restores conformance with.

     `.chat-panel` keeps the "outer column is a real flex item, inner panel
     is `position: absolute; inset: 0`" split intact - that inner mechanism
     is unrelated to whether the outer column overlays or shares the row,
     and still exists so a tall message list can never grow `.chat-column`
     (and therefore the row) past the video column's own height; see the
     unconditional `.chat-panel` rule and docs/comet/changes/
     chat-column-height/brief.md. What changes here is only the *inset*
     values, from the unconditional `inset: 0` to margins that keep the
     panel reading as a floating glass panel rather than a flush-mounted
     sidebar, consistent with the Aero identity this page uses throughout:
     `top` clears the floating titlebar (same `var(--room-titlebar-height)
     + 0.75rem` idiom `.stream-tile-label` now also uses, so the two can
     never drift out of sync independently), `right`/`bottom` give the
     usual `0.75rem` margin from the row's own edges, and `left: 0` is
     deliberately flush with the column's own left edge - the space
     between chat and video is already `--room-gap`, provided by the flex
     layout itself, not a second inset stacked on top of it. Below this
     breakpoint chat has no spare width to float anything beside the video
     at all, so the narrow-viewport media query above leaves both
     `.chat-column` and `.chat-panel` at their unconditional (in-flow,
     `inset: 0`) rules, unrelated to this block. */
  .chat-panel {
    top: calc(var(--room-titlebar-height) + var(--inset-chrome));
    right: var(--inset-chrome);
    bottom: var(--inset-chrome);
    left: 0;
  }

  /* `.chat-toggle-collapse` ("Hide chat") is positioned absolute against `.chat-column`, not
     `.chat-panel` (see its own unconditional rule's comment) - so moving the titlebar clearance
     from `.chat-column` to `.chat-panel` above (this fix) left the button's unconditional
     `top: var(--inset-chrome)` measuring from `.chat-column`'s own top edge, which at this
     breakpoint is the row's top edge, not the panel's - i.e. squarely under the floating
     titlebar, where a real browser run of scripts/measure-room-layout.mjs's review-screenshot
     pass caught the titlebar's own "Log out"/"Leave room" button (`z-index: 10`, above this
     button's `z-index: 3`) intercepting its clicks. Same `var(--room-titlebar-height) +
     var(--inset-chrome)` clearance `.chat-panel` just used, that part unchanged.

     chat-toggle-alignment adds the rest of both axes on top of that base clearance, for the same
     reason the unconditional rule above now does (see its comment): `.chat-panel` itself gets an
     extra `--inset-chrome` *outer* margin from `.chat-column`'s edges at this breakpoint (the rule
     above), which the unconditional `.chat-toggle-collapse` rule's `--chat-panel-inset`-based
     offsets don't know about - they're measured from `.chat-panel`'s own edges, and at this
     breakpoint those edges have moved in from `.chat-column`'s. Both axes below just add that same
     `--inset-chrome` back on top of the unconditional rule's value, so the button keeps tracking
     the panel's real position instead of drifting back toward the column's raw edge. `top` still
     carries the unconditional rule's own `1px` (`.aero-panel`'s border - see that rule's comment)
     ahead of `--chat-panel-inset`, for the same reason it needs it there. */
  .chat-toggle-collapse {
    top: calc(var(--room-titlebar-height) + var(--inset-chrome) + 1px + var(--chat-panel-inset) - (2.55rem - (1.1rem * 1.3)) / 2);
    right: calc(var(--inset-chrome) + var(--chat-panel-inset));
  }

  /* Chat collapse (chat-collapse), desktop/tablet half - superseded again by
     titlebar-chat-toggle-relocation (room-layout-defects follow-up). History, oldest to newest:

     - Original: collapsing reassigned `--chat-width` to `5.5rem` (just wide enough to hold the
       "Show chat" tab) instead of to zero, so `.aero-player-stage`'s unconditional width formula
       (`calc(100% - var(--chat-width) - var(--room-gap))`, above) kept subtracting that strip
       from the video even though nothing about the button needs a flex track at all once
       collapsed - measured directly, that cost the video ~88px (5.5rem) of width it did not need
       to give up, at every width-bound viewport.
     - tile-chrome-round2 item 2b fixed that by docking `.chat-column` out of the flex row
       entirely (`position: absolute`) as a floating "Show chat" pill over the video's top-right
       corner, leaving `--chat-width` untouched since there was nothing left in the row to
       subtract a chat width FOR.
     - That floating pill turned out to be its own defect, found the same way the ~88px waste
       above was: by actually measuring, not just reasoning about the CSS. At 1280x800 with three
       tiles, the docked pill's `top`/`right` corner overlapped real tile chrome sharing that same
       corner - `.stream-tile-pip-toggle`/`.live-edge-pill` - not empty video, and not merely a
       near miss; the same class of defect `.go-live-button`'s own top-right-row fix elsewhere in
       this file already had to solve once for tile chrome colliding with itself.

     The fix this time is to stop docking anything here at all: `.chat-column` is simply
     `display: none` - out of flow AND out of paint, so there is nothing left for it to collide
     with regardless of where any other chrome happens to sit. The one control that column ever
     held while collapsed, "Show chat" itself, now lives in `#room-titlebar-meta` instead (see
     chat.js's ensureChatToggleButtons, which creates a second, separate copy of the button there,
     and this rule's own `.room-titlebar-meta .room-chat-expand-toggle` sibling further down this
     file for how the two copies stay mutually exclusive per breakpoint) - a cluster that already
     reserves its own space at the top of the page and has never needed to share a corner with
     tile chrome. `--chat-width` stays untouched for the same reason it always has since
     tile-chrome-round2: `.chat-column` contributes nothing to the row either way, so the row has
     nothing to subtract a chat width FOR - `.aero-player-stage`'s override below claims the row's
     width outright. */
  .room-columns.chat-collapsed .chat-column {
    display: none;
  }

  /* Reclaims the row width chat's own flex track used to occupy: with `.chat-column` fully
     `display: none` above, `.aero-player-stage` is once again the ONLY thing sharing this row, so
     its own bound is simply the row's full width - still `min()`-ed against the same height bound
     the unconditional rule above uses (collapsing chat does not change which axis binds, only how
     much width the width-bound branch has to work with). */
  .room-columns.chat-collapsed .aero-player-stage {
    width: min(100%, calc(100cqh * 16 / 9));
  }
}

/* =====================================================================
   Room chrome: full-bleed shell, floating titlebar, real chat column
   =====================================================================
   The room page has one visual treatment: full-bleed video with thin,
   translucent chrome floating over it, rather than a bordered window inset
   from the viewport - see docs/comet/changes/theater-as-default/brief.md
   ("Why the page is not a window in a field") and specs/
   room-view-presentation/spec.md. This design shipped first as an opt-in
   beta ("Theater", selected by a cookie and a `theme-theater` body class,
   contrasted against a "Classic" bordered-window default); the owner then
   promoted it to the room page's only look and deleted Classic outright,
   which is what removed the `.theme-theater` scoping every rule below used
   to need. The shell/window-chrome zeroing (`.room-shell`/`.room-window`/
   `.room-columns`/`.aero-player-stage` padding, all above), the bottom-
   anchored `.chat-messages`, and the default `object-fit: contain` on
   `.stream-tile video`/`.self-preview-tile video` moved directly into
   those elements' own base rules for the same reason and are not repeated
   here - only the room-specific chrome that has no other unconditional
   home (the titlebar and its meta cluster - viewer count, live pill,
   fit/fill, cinema, Leave/Log-out - plus the titlebar's own idle-hide
   states) lives in this section. "Real chat column," not "glass chat overlay," in the
   heading above: theater-as-default originally shipped chat as a floating
   overlay docked over the video's edge; room-layout-defects put it back as
   a real flex track (see `.chat-column`/`.aero-player-stage`'s own
   comments in the `@media (min-width: 901px)` block above) after
   measurement found the overlay hid 12-25% of the picture at common
   desktop sizes. Chat is still glass (`.aero-panel`) - it just no longer
   floats *over* the video to get that look.

   On a wide, short viewport `.aero-player-stage` is height-bound (the
   `min(calc(100% - var(--chat-width) - var(--room-gap)), calc(100cqh * 16
   / 9))` formula above picks the height branch), so every pixel of
   *height* reclaimed from chrome returns 16/9 = 1.78 pixels of *width* once
   `aspect-ratio` re-derives it - taking the titlebar out of flex flow
   entirely (below) is worth more there than its own ~44px might suggest. */

.room-titlebar {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  z-index: 10;
  height: var(--room-titlebar-height);
  padding: 0 8px 0 16px;
  border-radius: var(--radius-panel) var(--radius-panel) 0 0;
  background: var(--glass-lit), rgba(28,36,76,.62);
  border: 0;
  border-bottom: 1px solid rgba(232,239,255,.28);
  box-shadow: inset 0 1px 0 rgba(232,239,255,.42);
  backdrop-filter: var(--blur-panel);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}

/* Room name and eyebrow in design-pass 1: eyebrow is now visible in the
   titlebar, forming a stacked label block with the h1 below it. */
.room-titlebar > div:first-child {
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 1px;
  min-width: 0;
}

.room-titlebar .eyebrow {
  display: block;
  font-weight: 600;
  font-size: 9.5px;
  line-height: 1;
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--dim);
}

.room-titlebar h1 {
  font-weight: 600;
  font-size: 15px;
  line-height: 1.15;
  color: var(--text);
  white-space: nowrap;
  margin: 0;
}

/* Design-pass 1: titlebar meta cluster layout and styling */
.room-titlebar-meta {
  display: flex;
  flex-direction: row;
  align-items: center;
  gap: 8px;
  justify-content: flex-end;
  /* Wrap stays the narrow-viewport safety valve (see the mobile block); the desktop rule right
     below pins the cluster to one line. */
  flex-wrap: wrap;
  max-width: 100%;
}

/* Viewer count pill - design-pass 1 styling */
.room-shell .viewer-count-pill {
  height: var(--pill-h);
  padding: 0 12px;
  display: inline-flex;
  align-items: center;
  border-radius: 999px;
  background: var(--ink-faint);
  border: 1px solid rgba(193,213,255,.24);
  font-weight: 600;
  font-size: 12px;
  line-height: 1;
  color: var(--muted);
  box-shadow: none;
}

/* Live status pill - design-pass 1 styling */
.room-shell .room-titlebar-meta .status-pill.live {
  height: var(--pill-h);
  padding: 0 12px 0 10px;
  gap: 7px;
  background: rgba(101,240,197,.12);
  border: 1px solid rgba(101,240,197,.45);
  box-shadow: 0 0 16px rgba(101,240,197,.22);
  font-weight: 600;
  font-size: 12px;
  line-height: 1;
  color: var(--success);
}

.room-shell .room-titlebar-meta .status-pill.live > span {
  width: 7px;
  height: 7px;
  background: var(--success);
  box-shadow: 0 0 8px var(--success);
  border-radius: 50%;
}

/* Titlebar buttons - GLASS BUTTON styling */
.room-shell .room-titlebar-meta .aero-button {
  height: var(--pill-h);
  min-height: 0;
  padding: 0 14px;
  border-radius: 999px;
  background: var(--btn-lit), rgba(42,54,112,.62);
  border: 1px solid var(--edge-lit);
  box-shadow: inset 0 1px 0 rgba(255,255,255,.45), 0 2px 8px rgba(0,0,0,.35);
  font-weight: 600;
  font-size: 12px;
  line-height: 1;
  color: var(--text);
  white-space: nowrap;
}

/* Show chat button - PRIMARY BUTTON styling */
.room-shell .room-titlebar-meta .room-chat-expand-toggle {
  background: var(--btn-lit-strong), var(--accent-gradient);
  border-color: rgba(232,239,255,.6);
  box-shadow: inset 0 1px 0 rgba(255,255,255,.5), 0 6px 18px rgba(160,108,255,.35);
  color: #fff;
}

/* Sign-out button - DANGER BUTTON styling */
.room-shell .room-titlebar-meta .aero-button.signout {
  background: linear-gradient(180deg, rgba(255,255,255,.22), rgba(255,255,255,.04) 50%, rgba(255,255,255,0) 52%), var(--danger-strong);
  border-color: rgba(255,101,138,.6);
  box-shadow: inset 0 1px 0 rgba(255,255,255,.35), 0 2px 8px rgba(0,0,0,.35);
  color: #ffe3ea;
}

/* Titlebar dividers - design-pass 1 */
/* Segmented two-option controls in the titlebar (design pass 3): both options visible, the active
   one lit. The group is not an .aero-button, so the cluster's pill recipe above does not apply to
   it; options are real <button>s (Tab moves between them, no roving tabindex). */
.room-shell .titlebar-segment {
  height: var(--pill-h);
  display: inline-flex;
  align-items: stretch;
  border-radius: 999px;
  background: var(--ink-faint);
  border: 1px solid rgba(193,213,255,.28);
  padding: 2px;
  gap: 2px;
  flex: none;
}

.room-shell .titlebar-segment-option {
  min-height: 0;
  width: auto;
  padding: 0 12px;
  border-radius: 999px;
  border: 1px solid transparent;
  background: transparent;
  box-shadow: none;
  font-size: 12px;
  font-weight: 600;
  line-height: 1;
  color: var(--muted);
  white-space: nowrap;
  cursor: pointer;
  transition: background-color var(--motion-fast) var(--ease-out), color var(--motion-fast) var(--ease-out), border-color var(--motion-fast) var(--ease-out);
}

.room-shell .titlebar-segment-option[aria-pressed="true"] {
  border-color: rgba(232,239,255,.5);
  background: var(--btn-lit-strong), rgba(42,54,112,.9);
  box-shadow: inset 0 1px 0 rgba(255,255,255,.45);
  color: var(--text);
}

.room-shell .titlebar-segment-option:hover {
  color: var(--text);
}

@media (prefers-reduced-motion: reduce) {
  .room-shell .titlebar-segment-option {
    transition: none;
  }
}

/* Room design pass, motion pass (Claude Design Room.dc.html, second revision): a handful of
   keyframe animations, all transform/opacity only, all switched off under prefers-reduced-motion.
   - Every name pill carries a glowing dot that blinks slowly while the stream is live; it turns
     amber when the viewer has scrubbed behind the live edge (the standalone LIVE pill is gone; the
     amber "-Ns" pill still appears behind live because it is the return-to-live control).
   - The titlebar's "Live grid" dot breathes.
   - Chat history rises in on first load (staggered 70ms per card, oldest first); new messages keep
     the existing enter transition.
   - Primary gradient buttons (Send, Show chat, New messages) carry a slow sheen; the New messages
     pill bobs. */
@keyframes ss-blink { 0%, 100% { opacity: 1; } 50% { opacity: .25; } }
@keyframes ss-pulse { 0%, 100% { transform: scale(1); opacity: .85; } 50% { transform: scale(1.15); opacity: 1; } }
@keyframes ss-rise { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } }
@keyframes ss-sheen { 0% { transform: translateX(-120%) skewX(-18deg); } 35%, 100% { transform: translateX(320%) skewX(-18deg); } }
@keyframes ss-bob { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-3px); } }

.room-shell .stream-tile-label::before {
  content: '';
  flex: none;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--success);
  box-shadow: 0 0 8px var(--success);
  animation: ss-blink 1.6s ease-in-out infinite;
}
.room-shell .stream-tile:has(.live-edge-pill.behind-live) .stream-tile-label::before {
  background: var(--warning);
  box-shadow: 0 0 8px var(--warning);
}
.room-shell .arranged-rail .stream-tile-label::before {
  display: none; /* thumbnails carry the name only */
}
/* At the live edge the label's dot IS the live indicator; the pill only shows behind live, where it
   is the return-to-live control (design revision 2 dropped the green LIVE pill). Its fixed slot is
   kept (see --live-pill-width) so the streamer's self-view toggle never collides with it. */
.room-shell .live-edge-pill:not(.behind-live) {
  display: none;
}
.room-shell .room-titlebar-meta .status-pill.live > span:first-child {
  animation: ss-pulse 2.4s ease-in-out infinite;
}
body.motion-ready .chat-message.chat-message-rise {
  animation: ss-rise 260ms var(--ease-out) both;
  animation-delay: var(--chat-rise-delay, 0ms);
}
.room-shell .chat-form .aero-button.primary,
.room-shell .room-titlebar-meta .room-chat-expand-toggle,
.room-shell .chat-toggle-expand,
.room-shell .chat-new-messages-pill-button {
  position: relative;
  overflow: hidden;
}
.room-shell .chat-form .aero-button.primary::after,
.room-shell .room-titlebar-meta .room-chat-expand-toggle::after,
.room-shell .chat-toggle-expand::after,
.room-shell .chat-new-messages-pill-button::after {
  content: '';
  position: absolute;
  top: -10%;
  bottom: -10%;
  left: 0;
  width: 28%;
  background: linear-gradient(90deg, transparent, rgba(255,255,255,.45), transparent);
  animation: ss-sheen 5s ease-in-out infinite;
  pointer-events: none;
}
.room-shell .chat-new-messages-pill {
  /* Three bobs to draw the eye, then still: a click target that never stops moving is hostile to
     the viewer and to any automation waiting for it to settle. */
  animation: ss-bob 1.6s ease-in-out 3;
}
@media (prefers-reduced-motion: reduce) {
  .room-shell .stream-tile-label::before,
  .room-shell .room-titlebar-meta .status-pill.live > span:first-child,
  body.motion-ready .chat-message.chat-message-rise,
  .room-shell .chat-form .aero-button.primary::after,
  .room-shell .room-titlebar-meta .room-chat-expand-toggle::after,
  .room-shell .chat-toggle-expand::after,
  .room-shell .chat-new-messages-pill-button::after,
  .room-shell .chat-new-messages-pill {
    animation: none;
  }
}

.titlebar-divider {
  width: 1px;
  height: 20px;
  background: rgba(193,213,255,.24);
  margin: 0 4px;
  flex: none;
}

/* Specificity insurance, not a fix for an active defect: `.room-titlebar-meta .aero-button`
   (two classes, immediately above) already outranks `button[hidden]` (this file's own comment
   near ~line 717) on class-count alone, so if that rule - or any future rule at this same scope -
   ever grows its own `display` declaration, `hidden` would silently lose again for every pill in
   this cluster, the "Show chat" titlebar pill (`.room-chat-expand-toggle`) included, exactly the
   `button[hidden]`-loses-to-an-author-rule trap that comment already documents once. */
.room-titlebar-meta .aero-button[hidden] {
  display: none;
}

.theater-fill .stream-tile video,
.theater-fill .self-preview-tile video {
  object-fit: cover;
}

@media (min-width: 1200px) {
  .grid-many { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}

@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  .aero-window,
  .aero-panel,
  .self-preview-tile::before,
  .stream-tile-label,
  .go-live-button,
  .stream-tile-interrupted,
  .stream-tile-grip {
    background-color: rgba(18, 24, 58, 0.92);
  }
}

/* Host stream health */

/* The stream-health card (host.html's "Stream health" panel, per room)
   deliberately reuses `.nested-panel`/`.connection-list`/`.connection-row`
   verbatim from the "Connect OBS" panel right below it - same glass/panel
   idiom, no new visual language for a fourth stat list. The one thing
   worth a rule of its own: `.connect-panel h3` already tightens the base
   `h3 { margin-bottom: 0.65rem }` (line ~90) down to 0.5rem as part of the
   host-dashboard-redesign "stop making hosts scroll" budget - this panel
   adds real, new vertical content (a stat a host could not see at all
   before), so it earns the same tightening its sibling panel already has,
   rather than reintroducing the extra ~2.4px this page's redesign already
   fought to remove once. See scripts/measure-host-layout.mjs for the
   current scrollHeight numbers with this card included - it now adds a
   few real rows of content per room, so a narrower viewport that
   previously just fit may scroll a little more than before; the
   structural invariants that harness gates on (both grid columns having
   width, exactly one card per room, tabs appearing only for 2+ rooms)
   are unaffected. */
.stream-health-panel h3 {
  margin-bottom: 0.5rem;
}

/* ==========================================================================
   Login & Gate design refresh (login-gate-design-spec, chosen Login
   direction "3a: Backstage + Signal"; Gate Artboards 1a-1d). Everything in
   this section is scoped under `.auth-window`/`.auth-shell`/`.auth-titlebar`
   and touches no other page. `:has(.gate-content)` is this section's own
   "which auth page is this" switch - see `.auth-window`'s own comment
   above for why a class on the window itself is not available. New
   keyframes: ss-ring (orb ring), ss-eq (level-meter bars), ss-ghost
   (backstage tiles) - ss-rise/ss-sheen/ss-pulse already existed and are
   reused verbatim, not redefined.
   ========================================================================== */

@keyframes ss-ring { 0% { transform: scale(.6); opacity: .7; } 100% { transform: scale(2.2); opacity: 0; } }
@keyframes ss-eq { 0%, 100% { transform: scaleY(.3); } 50% { transform: scaleY(1); } }
@keyframes ss-ghost { 0%, 100% { opacity: .1; transform: translateY(0); } 50% { opacity: .2; transform: translateY(-4px); } }

/* Custom focus ring (login-gate-design-spec's "Implementation Notes":
   `outline: 2px solid rgba(98,199,255,.8)`), scoped to the auth pages only
   - every other page keeps its existing `input:focus` border/glow alone,
   which already exists site-wide and is unaffected here. */
.auth-shell input:focus {
  outline: 2px solid rgba(98,199,255,.8);
  outline-offset: 2px;
}

/* --- Login only: breathing orb + ring, titlebar sheen, button sheen, level
   meter, backstage ghost grid. Every selector below is anchored on
   `.auth-window:not(:has(.gate-content))` so none of it leaks onto the
   gate page, which keeps a static orb and no sheens (see the gate titlebar
   rules above, and login-gate-design-spec's own "orbs and sheens... do not
   convey state" - the gate's second, secondary sign-in path is deliberately
   the quieter one). --- */

.auth-window:not(:has(.gate-content)) .auth-titlebar .orb {
  position: relative;
  animation: ss-pulse 3.2s ease-in-out infinite;
}

.auth-window:not(:has(.gate-content)) .auth-titlebar .orb::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: 50%;
  border: 1px solid rgba(217,183,255,.7);
  animation: ss-ring 3.2s ease-out infinite;
  pointer-events: none;
}

.auth-window:not(:has(.gate-content)) .auth-titlebar {
  position: relative;
  overflow: hidden;
}

.auth-window:not(:has(.gate-content)) .auth-titlebar::after {
  content: "";
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  width: 22%;
  background: linear-gradient(90deg, transparent, rgba(255,255,255,.28), transparent);
  animation: ss-sheen 6s ease-in-out infinite;
  pointer-events: none;
}

/* Full form width and a true pill radius: the base `.aero-button` rule sizes to its own text
   content (`width: fit-content`) and uses `--radius-sm` (11.2px), both wrong for this button -
   found the same way as the `.auth-panel` bug above, by rendering the page and measuring
   (`getBoundingClientRect()` against the button's own form: it was an 89px pill sitting at the
   form's left edge, not a full-width CTA). `var(--radius-pill)` (999px, already declared at
   :root) is reused rather than a bare literal so this stays in step with every other pill
   control in the app. Scoped to the auth pages only - `.aero-button.primary` is also the
   dashboard's own CTA styling elsewhere, which must not change shape. */
.auth-window:not(:has(.gate-content)) .aero-button.primary {
  position: relative;
  overflow: hidden;
  width: 100%;
  min-height: 46px;
  margin-top: 4px;
  border-radius: var(--radius-pill);
}

@media (max-width: 900px) {
  .auth-window:not(:has(.gate-content)) .aero-button.primary {
    min-height: 48px;
  }
}

.auth-window:not(:has(.gate-content)) .aero-button.primary::after {
  content: "";
  position: absolute;
  top: -10%;
  bottom: -10%;
  left: 0;
  width: 28%;
  background: linear-gradient(90deg, transparent, rgba(255,255,255,.45), transparent);
  animation: ss-sheen 4.5s ease-in-out infinite;
  pointer-events: none;
}

.auth-heading {
  display: flex;
  flex-direction: column;
  gap: 6px;
  margin-bottom: 18px;
}

.auth-heading h1 {
  display: flex;
  align-items: baseline;
  gap: 12px;
  margin: 0;
  font-weight: 600;
  font-size: 28px;
  line-height: 1.1;
  letter-spacing: -0.01em;
  color: #f6f8ff;
  text-shadow: none;
}

/* Five-bar equalizer beside the "Shadow Stream" wordmark - purely decorative
   (aria-hidden in login.html), each bar on its own duration/delay so the
   five don't move in lockstep. */
.auth-eq {
  display: inline-flex;
  align-items: flex-end;
  gap: 3px;
  height: 22px;
}

.auth-eq span {
  display: block;
  width: 4px;
  height: 22px;
  border-radius: 2px;
  background: linear-gradient(180deg, #62c7ff, #a06cff);
  transform-origin: bottom;
  animation: ss-eq 1.1s ease-in-out infinite;
}

.auth-eq span:nth-child(1) { animation-duration: 1.1s; animation-delay: -.3s; }
.auth-eq span:nth-child(2) { animation-duration: 1.4s; animation-delay: -.6s; }
.auth-eq span:nth-child(3) { animation-duration: .9s; animation-delay: -.15s; }
.auth-eq span:nth-child(4) { animation-duration: 1.3s; animation-delay: -.45s; }
.auth-eq span:nth-child(5) { animation-duration: 1s; }

/* Backstage ghost grid (login.html only): four faded, aria-hidden livestream tiles behind the
   window - see login.html's own comment. `position: absolute` (not `fixed`) against
   `.auth-shell`'s own `isolation: isolate` stacking context (see that rule's comment) - a
   `fixed`-positioned element still establishes its own stacking context, but stacking contexts
   nest by DOM ancestry regardless of positioning scheme, so `fixed` was never actually the
   problem; `.auth-shell` not reliably guaranteeing itself as the isolation boundary was. `inset:
   0` against `.auth-shell` (which spans at least the full viewport via `.aero-shell`'s own
   `min-height: 100vh`) covers the same area a viewport-relative `fixed` box did. `z-index: 0`
   here and `z-index: 1` on `.auth-window` (below) are both explicit integers - never `auto` -
   so which one is "in front" is never ambiguous. */
.auth-ghost-grid {
  /* Decorative off-screen tiles must not widen the phone page. */
  overflow: clip;
  position: absolute;
  inset: 0;
  z-index: 0;
  display: grid;
  grid-template-columns: repeat(2, 560px);
  grid-template-rows: repeat(2, 315px);
  gap: 48px;
  justify-content: center;
  align-content: center;
  pointer-events: none;
  /* The design softens the ghost grid with a full-page overlay carrying `backdrop-filter:
     blur(6px)` and relies on the window's own `backdrop-filter` to finish the job. In Chromium
     that second step never happens here: `body::after` (the site-wide `mix-blend-mode: screen`
     sheen) paints between this grid and `.auth-window` inside body's stacking context, and a
     blended sibling caps what a later backdrop-filter may sample, so the window received the
     grid's *unblurred* pixels and the tile labels ("Nyx", "LIVE") showed through the login card
     crisp, at every viewport, while z-order and hit-testing were both provably correct. Blur the
     grid itself instead: same look, no dependency on what the compositor lets a backdrop see. */
  filter: blur(6px);
}

.auth-ghost-tile {
  position: relative;
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  padding: 12px 14px;
  border-radius: 14px;
  opacity: .15;
  background: radial-gradient(60% 70% at 40% 45%, #3a3d48, #141621 70%);
  box-shadow: 0 0 0 1px rgba(193,213,255,.2);
  animation: ss-ghost 9s ease-in-out infinite;
}

.auth-ghost-tile:nth-child(2) { animation-delay: 3s; }
.auth-ghost-tile:nth-child(3) { animation-delay: 6s; }
.auth-ghost-tile:nth-child(4) { animation-delay: 4.5s; }

.auth-ghost-name {
  font-weight: 600;
  font-size: 13px;
  color: #c1d5ff;
}

.auth-ghost-live {
  font-weight: 700;
  font-size: 10px;
  letter-spacing: .08em;
  color: var(--success);
  border: 1px solid rgba(101,240,197,.5);
  border-radius: 999px;
  padding: 2px 6px;
}

/* --- Gate only: two-pane desktop layout. `minmax(0,1fr)` (not a bare
   `1fr`) on the flexible column is required - a `1fr` track alone refuses
   to shrink a grid item below its own content's intrinsic width, the same
   overflow trap `.aero-player-stage`'s own "min-width: 0" comment
   documents for the room grid. Exactly three top-level children in
   gate.html (`.gate-pane-left`, `.gate-divider`, `.gate-pane-right`) land
   in the three template columns via plain auto-placement - no explicit
   `grid-column` needed anywhere here. --- */
@media (min-width: 901px) {
  .gate-content {
    padding: 0;
    display: grid;
    grid-template-columns: minmax(0,1fr) 1px 340px;
    align-items: stretch;
  }

  /* Both gate panels sit flush inside the shared window at this width (see `.auth-window
     .auth-panel`'s own unconditional reset above, which this mirrors for the gate) - desktop-
     only, because the `@media (max-width: 900px)` block below removes the shared window frame
     entirely and each panel needs to look like an independent card again there. `.gate-panel`
     also matches `.gate-host-panel` (it carries both classes), so both flush panels get the same
     reset, `border-radius: 0` included - measured in a real browser (not just reasoned about)
     that omitting it left a still-visibly-rounded 16px corner on an otherwise flat, borderless
     panel, the same class of miss `.auth-window .auth-panel`'s own fix above documents. This
     rule already wins on specificity (`.auth-window .gate-panel`, two classes, vs. the base
     `.aero-panel`'s one), unlike that other bug, which was a source-order tie - two classes
     covers both cases regardless of which one actually applies. */
  .auth-window .gate-panel {
    border: none;
    border-radius: 0;
    background: none;
    box-shadow: none;
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
  }

  .auth-window .gate-panel::before {
    content: none;
  }

  /* Adds back just a background tint for the streamer pane (login-gate-design-spec: "Right
     pane: fixed 340px width, background: rgba(5,7,17,.18)") without reinstating the border/
     shadow/backdrop-filter the reset above removed. `.gate-host-panel`'s own extra class is
     what wins the specificity tie against the reset immediately above, not source order. */
  .auth-window .gate-panel.gate-host-panel {
    background: none;
  }

  .gate-pane-left,
  .gate-pane-right {
    display: flex;
    flex-direction: column;
    gap: 16px;
    min-width: 0;
  }

  .gate-pane-left {
    padding: 28px 32px 32px;
  }

  .gate-pane-right {
    padding: 28px 28px 32px;
    /* The tint belongs to the pane (the grid cell), not the panel inside it: on the panel it
       stopped 28px short of every edge and read as a card floating in the column. */
    background: rgba(5,7,17,.18);
  }

  .gate-divider {
    align-self: stretch;
    margin: 20px 0;
    background: var(--edge-soft);
  }

  /* Both panels sit flush in the shared window now (see `.auth-window
     .gate-panel`'s reset above); their own copy of the shared
     `.auth-panel, .gate-panel, ... { padding: ... }` rule would double
     the pane's own padding on top of it. */
  .auth-window .gate-panel {
    padding: 0;
  }

  .gate-pane-left h2 {
    font-size: 22px;
    line-height: 1.2;
  }

  .gate-pane-right h2 {
    font-size: 17px;
    line-height: 1.2;
  }

  .gate-pane-left .aero-button.primary {
    min-height: 44px;
    padding: 0 24px;
  }

  /* The streamer sign-in button is deliberately the quieter of the two -
     smaller, flatter, no accent gradient - login-gate-design-spec's "told
     apart at a glance" extends to the buttons themselves, not just the
     panes. */
  .gate-pane-right .aero-button {
    min-height: 40px;
    padding: 0 20px;
    font-size: 13px;
    background: var(--btn-lit), rgba(42,54,112,.62);
    border-color: rgba(232,239,255,.42);
    box-shadow: inset 0 1px 0 rgba(255,255,255,.45), 0 2px 8px rgba(0,0,0,.35);
  }
}

/* Both gate buttons want the same true pill radius login's own button fix above documents (the
   base `.aero-button` rule uses `--radius-sm`, 11.2px, not a pill) - unconditional (not
   media-query-scoped) since it is identical at every viewport, unlike the per-viewport
   height/padding overrides above and below. */
.gate-pane-left .aero-button.primary,
.gate-pane-right .aero-button {
  border-radius: var(--radius-pill);
}

/* --- Gate only (mobile): the shared window frame is removed - each panel
   becomes its own independent card again, exactly like the pre-redesign
   stacked layout, and the streamer panel gets a distinct muted background
   instead of the old flat `opacity: 0.92`. --- */
@media (max-width: 900px) {
  /* The generic narrow-viewport `.aero-titlebar` rule (above) stacks title
     over actions in a column; both auth titlebars stay one row, always -
     the same fix `.room-titlebar`'s own mobile rule already applies for
     the identical reason (see that rule's comment). */
  .auth-titlebar {
    flex-direction: row;
    align-items: center;
  }

  .auth-window:has(.gate-content) {
    border: none;
    background: none;
    box-shadow: none;
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
    border-radius: 0;
    overflow: visible;
    animation: none;
  }

  .gate-content {
    padding: 12px;
  }

  .gate-divider {
    display: none;
  }

  .gate-pane-right {
    margin-top: 12px;
  }

  /* No explicit border-radius override needed here: with the desktop-only reset above no
     longer in effect at this width, `.gate-panel`/`.gate-host-panel` fall back to the shared
     `.aero-panel` rule's own `border-radius: var(--radius-lg)` (1rem = 16px), which already
     matches this spec's mobile card radius. */
  .gate-host-panel {
    background:
      linear-gradient(180deg, rgba(255,255,255,0.08), transparent 3rem),
      rgba(28,36,76,.4);
    border-color: rgba(193,213,255,.24);
    box-shadow: inset 0 1px rgba(232,239,255,.25);
  }

  .gate-pane-left h2 {
    font-size: 20px;
  }

  .gate-pane-right h2 {
    font-size: 16px;
  }

  .gate-pane-left .aero-button.primary {
    min-height: 48px;
  }

  /* The streamer button lost its glass treatment on mobile: the desktop-only `.gate-pane-right
     .aero-button` rule (inside `@media (min-width: 901px)`, further up) carries the whole
     background/border/box-shadow recipe, but this mobile block only ever overrode `min-height`
     - so below 901px the button fell all the way back to the base `.aero-button` rule's default
     accent gradient (purple-to-blue, the same look as the guest "Enter room" button), rendering
     as if it were `.primary` when it explicitly is not. `.gate-host-panel .aero-button` (rather
     than repeating `.gate-pane-right .aero-button`) targets the same button by its other class,
     so the mobile glass rule reads as "the streamer panel's button" independent of the pane
     wrapper's own desktop-only layout role. */
  .gate-host-panel .aero-button {
    min-height: 44px;
    background: var(--btn-lit), rgba(42,54,112,.62);
    border-color: rgba(232,239,255,.42);
    box-shadow: inset 0 1px 0 rgba(255,255,255,.45), 0 2px 8px rgba(0,0,0,.35);
  }

  .auth-ghost-grid {
    grid-template-columns: repeat(2, 300px);
    grid-template-rows: 168px;
    gap: 24px;
  }

  .auth-ghost-tile:nth-child(3),
  .auth-ghost-tile:nth-child(4) {
    display: none;
  }
}

@media (prefers-reduced-motion: reduce) {
  .auth-window,
  .auth-window:not(:has(.gate-content)) .auth-titlebar .orb,
  .auth-window:not(:has(.gate-content)) .auth-titlebar .orb::after,
  .auth-window:not(:has(.gate-content)) .auth-titlebar::after,
  .auth-window:not(:has(.gate-content)) .aero-button.primary::after,
  .auth-eq span,
  .auth-ghost-tile {
    animation: none;
  }
}

/* Shared viewing-room header and viewer-options panel. */
.room-options { margin: 0; padding: 0; border: 0; background: none; flex: none; }
.room-shell .room-titlebar-meta .room-options-trigger { display: inline-flex; gap: 10px; align-items: center; min-height: 30px; padding: 4px 12px; list-style: none; cursor: pointer; font-size: 12px; white-space: nowrap; }
.room-options-trigger::-webkit-details-marker { display: none; }
.room-options-panel { z-index: 100; position: fixed; inset: auto; margin: 0; width: 320px; max-width: calc(100vw - 16px); padding: 0; overflow-y: auto; color: #edf1ff; border: 1px solid #8594c185; border-radius: 12px; background: linear-gradient(140deg, #3d466af2, #181d34fa 45%); box-shadow: inset 0 1px 0 #ffffff35, 0 16px 48px #0009; backdrop-filter: blur(24px); font-family: 'Segoe UI', sans-serif; }
.room-options-panel::backdrop { background: transparent; }
.room-cinema-row { padding: 10px; border-bottom: 1px solid #bac9ff25; }
.room-shell .room-options-panel .room-cinema-toggle { display: flex; justify-content: flex-start; width: 100%; min-height: 42px; padding: 10px 14px; font-size: 14px; border-radius: 7px; }
.room-option-section { padding: 14px 18px; border-bottom: 1px solid #bac9ff1c; }
.room-option-section h3 { margin: 0 0 5px; color: #f0f2ff; font-size: 13px; font-weight: 600; }
.room-option-section p { margin: 0 0 12px; font-size: 12px; line-height: 1.5; color: #b8c2de; }
.room-shell .room-options-panel .titlebar-segment { display: flex; width: 100%; max-width: none; padding: 4px; box-sizing: border-box; gap: 4px; }
.room-shell .room-options-panel .titlebar-segment-option { flex: 1; min-width: 0; min-height: 36px; height: auto; width: auto; padding: 5px 10px; font-size: 12px; }
.room-options-panel button:focus-visible, .room-options-trigger:focus-visible { outline: 2px solid #9ee5ff; outline-offset: 2px; }
.room-reset { min-height: 34px; font-size: 12px; padding: 6px 12px; }
.room-chat-divider { position: absolute; left: -12px; top: 0; bottom: 0; width: 12px; z-index: 6; touch-action: none; cursor: col-resize; outline: none; }
.room-chat-divider::after { content: ''; position: absolute; width: 3px; height: 52px; border-radius: 5px; top: calc(50% - 26px); left: 4px; background: #9bb2e56b; box-shadow: 0 0 0 1px #080e21; transition: background-color .15s, box-shadow .15s; }
.room-chat-divider:hover::after, .room-chat-divider:focus-visible::after, .room-resizing .room-chat-divider::after { background: #a7e8ff; box-shadow: 0 0 12px #69bdfc80; }
.room-chat-divider:focus-visible { outline: 1px solid #a7e8ff; outline-offset: -2px; border-radius: 4px; }
.room-resizing, .room-resizing * { cursor: col-resize !important; user-select: none !important; }
@media (max-width: 900px) {
  .room-chat-divider, .room-width-section { display: none; }
  .room-shell .room-titlebar-meta { overflow: visible; padding: 0 8px 0 0; justify-content: flex-end; gap: 5px; }
  .room-shell .room-titlebar-meta .viewer-count-pill, .room-shell .room-titlebar-meta .status-pill, .room-shell .room-titlebar-meta > form { display: none; }
  .room-shell .room-titlebar > div:first-child { max-width: calc(100% - 154px); }
  .room-shell .room-titlebar-meta .room-options-trigger { width: auto; font-size: 12px; min-height: 34px; }
  .room-shell .room-options-panel .room-fit-toggle { display: flex; }
  .room-options-panel .aero-button { width: auto; }
}
@media (prefers-reduced-motion: reduce) { .room-chat-divider::after { transition: none; } }
.room-shell .room-options-panel .titlebar-segment { height: auto; min-height: 44px; border-radius: 9px; align-items: center; }
.room-shell .room-options-panel .titlebar-segment-option { border-radius: 6px; }

/* One room-wide command bar. The video and chat start on the same row beneath it. */
.room-shell .room-titlebar {
  position: relative;
  inset: auto;
  flex: 0 0 64px;
  width: 100%;
  height: 64px;
  box-sizing: border-box;
  padding: 0 20px;
  border-radius: 0;
  border: 0;
  border-bottom: 1px solid #a5b9e62b;
  background: linear-gradient(105deg, #30324cdd, #202a42e8 65%, #1b293ee8);
  box-shadow: inset 0 1px 0 #ffffff21, 0 4px 24px #0307133d;
  overflow: visible;
  gap: 20px;
}
.room-shell .room-titlebar .room-identity {
  display: flex;
  flex-direction: row;
  justify-content: flex-start;
  align-items: center;
  gap: 12px;
  flex: 1 1 auto;
  min-width: 0;
  max-width: none;
}
.room-emblem {
  display: grid;
  place-items: center;
  flex: 0 0 36px;
  height: 36px;
  color: #d9e4ff;
  border: 1px solid #b8c7ed40;
  border-radius: 9px;
  background: linear-gradient(145deg, #939de23d, #5a76ab15);
  box-shadow: inset 0 1px 0 #ffffff21;
}
.room-identity-text { min-width: 0; }
.room-shell .room-titlebar .eyebrow { margin: 0 0 5px; color: #a8b6d5; font-size: 9px; letter-spacing: .12em; }
.room-shell .room-titlebar h1 { font-size: 17px; line-height: 1.2; text-overflow: ellipsis; overflow: hidden; }
.room-shell .room-titlebar-meta { flex: 0 0 auto; height: auto; padding: 0; gap: 14px; overflow: visible; }
.room-shell .room-titlebar-meta .viewer-count-pill { border: 0; background: none; padding: 0; margin: 0; font-weight: 400; color: #b5c1d9; }
.room-shell .room-titlebar-meta .status-pill.live { border: 0; box-shadow: none; padding: 0; background: none; margin: 0 8px 0 0; color: #93decc; }
.room-shell .room-titlebar-meta .status-pill.live > .room-live-label { width: auto; height: auto; border-radius: 0; background: none; box-shadow: none; }
.room-shell .room-titlebar-meta .status-pill.live > span:first-child { width: 6px; height: 6px; box-shadow: 0 0 8px #76e9cc55; }
.room-shell .room-titlebar-meta .room-options-trigger,
.room-shell .room-titlebar-meta .aero-button.signout,
.room-shell .room-titlebar-meta .room-chat-expand-toggle {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: 36px;
  height: 36px;
  padding: 0 14px;
  border-radius: 7px;
  border: 1px solid #b6c9f04a;
  box-shadow: inset 0 1px 0 #ffffff21, 0 2px 5px #0002;
  background: linear-gradient(#667aa52e, #293b642e);
  font-size: 12px;
}
.room-shell .room-titlebar-meta .room-chat-expand-toggle[hidden] { display: none; }
.room-shell .room-titlebar-meta .aero-button.signout { color: #f2b5c5; border-color: #ce7b913d; background: linear-gradient(#80354c26, #48233926); }
.room-shell .titlebar-divider-session { height: 22px; background: #a5b9e629; }
.room-shell .room-chat-heading {
  display: flex;
  flex: 0 0 auto;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  padding: 12px 14px 8px;
}
.room-shell .room-chat-heading h2 { padding: 0; min-height: 0; font-size: 14px; }
.room-shell .room-chat-heading .chat-toggle-collapse {
  position: static;
  inset: auto;
  flex: 0 0 auto;
  width: auto;
  height: 30px;
  min-height: 30px;
  padding: 0 10px;
  border-radius: 6px;
  font-size: 11px;
  border-color: #b6c9f033;
  background: linear-gradient(#526a9138, #33425b38);
  box-shadow: inset 0 1px 0 #ffffff17;
}
.room-shell .chat-panel { background: linear-gradient(160deg, #303d5ed9, #141b32ef 55%); }
.room-shell .chat-panel::before { display: none; }
.room-shell .chat-notice { padding: 0 14px 12px; margin: 0; line-height: 1.45; color: #a2b0ce; font-size: 11px; }
.room-shell .stream-grid .stream-tile { --tile-chrome-top: 10px; }
.room-shell .arranged-rail { padding-top: 0; }
.room-shell #room-grid.grid-empty { padding-top: 0; }
@media (min-width: 901px) {
  .room-shell .chat-panel { inset: 0; border-radius: 12px; border-color: #a5b9e635; }
}
@media (max-width: 900px) {
  .room-shell .room-titlebar { flex-basis: 58px; height: 58px; padding: 0 12px; gap: 12px; }
  .room-shell .room-titlebar .room-identity { gap: 8px; }
  .room-emblem { display: none; }
  .room-shell .room-titlebar .eyebrow { font-size: 8px; letter-spacing: .06em; }
  .room-shell .room-titlebar h1 { font-size: 14px; }
  .room-shell .room-titlebar-meta { flex: 0 0 auto; gap: 0; }
  .room-shell .room-titlebar-meta .room-options-trigger { min-height: 34px; height: 34px; padding: 0 10px; }
  .room-shell .arranged-rail { padding-top: 8px; }
  .room-shell .room-chat-heading { padding: 10px 12px 8px; }
  .room-shell .chat-notice { padding: 0 12px 10px; }
}
@media (max-width: 900px) {
  .room-shell .room-titlebar-meta .room-chat-expand-toggle { display: none; }
  /* Reserve space for both admin navigation and playback controls. */
  .room-shell .room-titlebar.room-titlebar-admin > .room-identity:first-child {
    flex: 1 1 0;
    max-width: none;
  }
}

.room-options-panel[hidden] { display: none; }
.room-options-panel > form, .room-options-panel > a.signout { margin: 12px 18px; }
.room-options-panel > a.signout { display: inline-flex; }

/* Shared room-inspired presentation. :root preserves the cascade specificity
   of the approved styling without a preference, toggle or JavaScript dependency. */
:root body:not(.room-shell) {
  background: radial-gradient(ellipse at 12% 0%, #31255640, transparent 60%), radial-gradient(ellipse at 94% 4%, #244b6c38, transparent 52%), linear-gradient(160deg,#0b0e1d,#070910 80%);
  background-attachment: fixed;
}
:root body:not(.room-shell)::before { opacity: .12; }
:root body:not(.room-shell) .aero-panel::before,
:root body:not(.room-shell) .aero-button::after,
:root body:not(.room-shell) .aero-titlebar::after { display: none; }
:root body:not(.room-shell) .aero-panel {
  border: 1px solid #a5b9e62b;
  background: linear-gradient(160deg,#303d5e90,#141b32dd 65%);
  border-radius: 12px;
  box-shadow: inset 0 1px 0 #ffffff09,0 8px 24px #03071326;
}
:root body:not(.room-shell) .aero-panel .aero-panel {
  background: #10172a70;
  box-shadow: none;
  border-color: #a5b9e61f;
  border-radius: 9px;
}
:root body:not(.room-shell) .aero-titlebar {
  display: flex; align-items: center; gap: 14px;
  min-height: 64px; height: 64px; padding: 0 20px;
  border: 0; border-bottom: 1px solid #a5b9e62b; border-radius: 0;
  background: linear-gradient(105deg,#30324cdd,#202a42e8 65%,#1b293ee8);
  box-shadow: inset 0 1px 0 #ffffff21,0 4px 24px #0307133d;
}
:root body:not(.room-shell) .aero-titlebar h1 { font-size: 17px; line-height: 1.2; margin: 0; }
:root body:not(.room-shell) .aero-titlebar .eyebrow { font-size: 9px; letter-spacing: .12em; margin: 0 0 5px; color: #a8b6d5; }
:root body:not(.room-shell) .aero-titlebar > div:not(.titlebar-actions) { flex: 1; min-width: 0; }
:root body:not(.room-shell) .titlebar-actions { margin-left: auto; }
:root .site-emblem { flex: 0 0 34px; width: 34px; height: 34px; display: grid; place-items: center; border: 1px solid #b6c9f030; border-radius: 10px; background: linear-gradient(145deg,#9b8ee62e,#5bafd81a); color: #e0ddff; font-size: 13px; text-decoration: none; }
:root body:not(.room-shell) h2 { font-size: 18px; letter-spacing: -.015em; }
:root body:not(.room-shell) h3 { font-size: 15px; }
:root body:not(.room-shell) .muted { color: #a8b6d2; font-size: 13px; line-height: 1.55; }
:root body:not(.room-shell) label { font-size: 13px; color: #c8d3ea; }
:root body:not(.room-shell) :is(input,select,textarea) { font-size: 14px; min-height: 40px; border-radius: 7px; background: #0b1123dd; border: 1px solid #a5b9e636; box-shadow: inset 0 1px 3px #0003; }
:root body:not(.room-shell) .aero-button { min-height: 36px; height: auto; padding: 8px 14px; border-radius: 7px; border: 1px solid #b6c9f04a; background: linear-gradient(#667aa52e,#293b642e); box-shadow: inset 0 1px 0 #ffffff21,0 2px 5px #0002; font-size: 12px; }
:root body:not(.room-shell) .aero-button.primary { color: #fff; background: linear-gradient(110deg,#7764c8,#4385ae); border-color: #b6b4f780; box-shadow: inset 0 1px 0 #ffffff30,0 3px 10px #0003; }
:root body:not(.room-shell) .aero-button:is(.danger,.signout) { color: #f2b5c5; border-color: #ce7b913d; background: linear-gradient(#80354c26,#48233926); }
:root body:not(.room-shell) .aero-button:hover { filter: brightness(1.15); }
:root body:not(.room-shell) .aero-button[hidden] { display: none; }
:root body:not(.room-shell) .aero-tab { padding: 9px 16px; border: 1px solid transparent; border-radius: 7px; background: transparent; box-shadow: none; font-size: 13px; color: #a8b6d2; }
:root body:not(.room-shell) .aero-tab[aria-selected="true"] { background: #7282b526; border-color: #a5b9e62b; color: #eff4ff; }
:root body:not(.room-shell) .aero-tabs { gap: 6px; padding: 5px; border-radius: 10px; background: #151c3080; border-color: #a5b9e626; box-shadow: none; }
:root body:not(.room-shell) .status-pill { box-shadow: none; font-size: 11px; background: #10182970; }
:root body:not(.room-shell) :is(.action-row,.room-row,.user-row) { border-color: #a5b9e626; border-radius: 8px; }
:root .dashboard-shell .dashboard-window > .aero-content { padding: 24px; max-width: 1600px; width: 100%; margin: auto; }
:root .dashboard-shell .dashboard-card { padding: 20px; }
:root .dashboard-shell .obs-checklist { font-size: 13px; }
:root .dashboard-shell .titlebar-actions { gap: 8px; }
:root .auth-shell { display: flex; flex-direction: column; justify-content: flex-start; align-items: center; padding: 0; min-height: 100dvh; }
:root .auth-shell > .auth-titlebar { flex: 0 0 64px; width: 100%; z-index: 2; }
:root .auth-shell .auth-window { flex: 0 0 auto; width: min(440px,calc(100% - 32px)); max-height: none; margin: max(40px,calc((100dvh - 610px) / 2)) auto 40px; padding: 0; background: none; border: none; box-shadow: none; transform: none; animation: none; overflow: visible; }
:root .auth-shell .auth-window:has(.gate-content) { width: min(880px,calc(100% - 32px)); }
:root .auth-shell .auth-window > .aero-content { padding: 0; }
:root .auth-shell .auth-panel { padding: 28px; }
:root .auth-shell .auth-heading h1 { font-size: 26px; margin: 0 0 10px; }
:root .auth-shell .auth-heading { margin-bottom: 24px; }
:root .auth-shell .auth-eq,
:root .auth-shell .orb { display: none; }
:root .auth-shell .auth-ghost-grid { opacity: .25; }
:root .auth-shell .auth-titlebar-label { flex: 1; font-size: 17px; }
:root .auth-shell .gate-content { gap: 20px; grid-template-columns: 1fr 1fr; }
:root .auth-shell .gate-pane-right { border: 0; padding: 0; background: none; }
:root .auth-shell .gate-divider { display: none; }
:root .auth-shell .gate-panel { padding: 26px; height: 100%; }
:root .auth-shell .aero-form { gap: 16px; }
:root .auth-shell .gate-content .aero-button { width: 100%; }
@media(max-width:900px) {
:root body:not(.room-shell) .aero-titlebar { min-height: 58px; height: 58px; padding: 0 12px; gap: 10px; }
:root body:not(.room-shell) .aero-titlebar h1 { font-size: 15px; }
:root .auth-shell > .auth-titlebar { flex-basis: 58px; }
:root .dashboard-shell .dashboard-window > .aero-content { padding: 16px; }
:root .dashboard-shell .dashboard-card { padding: 16px; }
:root .dashboard-shell .titlebar-actions .aero-button { width: auto; padding: 8px 10px; }
:root .auth-shell .auth-window { margin: 32px auto; }
:root .auth-shell .gate-content { grid-template-columns: 1fr; gap: 16px; }
:root .auth-shell .auth-panel,
:root .auth-shell .gate-panel { padding: 22px; }
:root .auth-shell .auth-titlebar-label { font-size: 15px; }
}
:root body:not(.room-shell) .aero-titlebar .eyebrow { display: block; }
:root body:not(.room-shell) .aero-titlebar > div > p.muted { display: none; }
:root .auth-shell .auth-window .aero-button.primary { border-radius: 7px; }
:root body:not(.room-shell) .aero-titlebar { flex-direction: row; flex-wrap: nowrap; text-align: left; }
:root body:not(.room-shell) .aero-titlebar > div:not(.titlebar-actions) { width: auto; }
:root body:not(.room-shell) .aero-titlebar .titlebar-actions { flex: 0 0 auto; width: auto; align-self: center; }
:root body:not(.room-shell) .aero-titlebar h1 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }


:root .dashboard-titlebar > .site-emblem { order: -1; }
:root .dashboard-titlebar > div:first-child { display: block; }
/* Keep all account destinations reachable in a narrow console header. The
   decorative emblem and repeated label suffix yield space to the page title. */
@media (max-width: 480px) {
  :root .dashboard-titlebar > .site-emblem,
  :root .dashboard-titlebar .console-link-detail { display: none; }
  :root .dashboard-titlebar .eyebrow { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
}

/* Matrix pilot: origin identity is inspectable by touch and keyboard, separate
   from local verified status. Conditional markup keeps unlinked rooms unchanged. */
.chat-matrix-notice { color: #a3ddd2; overflow-wrap: anywhere; }
.chat-matrix-identity { display: inline; color: #75e1cf; font-size: .7rem; }
.chat-matrix-identity summary { display: inline; cursor: pointer; border: 1px solid #4b8f87; border-radius: 5px; padding: 1px 5px; }
.chat-matrix-identity[open] { display: block; }
.chat-matrix-identity span, .matrix-sender-id { overflow-wrap: anywhere; }
.chat-matrix-identity span { display: block; padding-top: .35rem; }
.chat-matrix-identity summary:focus-visible { outline: 2px solid #75e1cf; outline-offset: 3px; }
.chat-relay-delivery { display: block; color: #a9b3c9; font-size: .65rem; margin-top: .2rem; }
.matrix-settings { margin-block: 1rem; }
.matrix-settings summary { cursor: pointer; font-weight: 600; }
.matrix-settings .aero-form { margin-top: .8rem; }
