/* ================================================================== *
 *  @jamunlabs/gameu-shell-v1 — lobby.css                               *
 *  TV-lobby chrome stylesheet for the gameu platform.                  *
 *                                                                      *
 *  Sibling to controller.css in the same package; both load on top    *
 *  of @jamunlabs/gameu-sdk's gameu-theme-v1.css. This sheet uses the   *
 *  theme's design tokens (--gameu-*, fonts, shadows, radii, strokes)  *
 *  and never redefines them. Load the theme first; this sheet will    *
 *  not render correctly without it.                                   *
 *                                                                      *
 *  Sections (in source order):                                         *
 *    1. Page (body background, root grid, corner z-stacking)           *
 *    2. Header strip (display name + "scan" link)                      *
 *    3. Lobby layout grid (games | qr / peers)                         *
 *    4. QR hero card + URL pills + info-card hide                      *
 *    5. Network-interface diagnostic (loopback URL fallback)           *
 *    6. Game carousel (.game-tiles + .game-tile + thumbs/labels)       *
 *    7. Tile state badges (Get / Installing / Failed / Unsupported)    *
 *    8. Peer strip (avatars, crowns, empty/pending states)             *
 *    9. Footer status chip strip                                       *
 *   10. Inline `code` pills                                            *
 *   11. TV toast overlay (lobby-side notifications)                    *
 *                                                                      *
 *  Each block carries Q-numbers in comments where the design choice    *
 *  is non-obvious. See https://github.com/jamunlabs/gameu under        *
 *  docs/decisions.md for the citation index.                           *
 * ================================================================== */


/* ============================================================
   1. PAGE
   ============================================================ */
body {
  /* halftone field + cyan ground from the theme's .gameu-stage */
  background: var(--gameu-cyan);
  background-image:
    radial-gradient(circle at 18px 18px, var(--gameu-halftone-color) 1.8px, transparent 2.4px),
    radial-gradient(circle at 4px 4px,   var(--gameu-halftone-color-faint) 1px, transparent 2px);
  background-size: 26px 26px, 14px 14px;

  font-family: var(--gameu-font-display);
  color: var(--gameu-navy);
  font-size: clamp(0.85rem, 1.6vh, 1.4rem);

  /* the four lobby-bound regions are positioned via grid-area */
  display: grid;
  grid-template-rows: auto 1fr auto;
  position: relative;
}

/* Decorative corner accents — mounted as <span> siblings of the
   Leptos root inside <body>. The theme's clip-path silhouettes
   produce yellow lightning-bolts in each corner; we just have to
   place them. */
.gameu-corner { z-index: 0; }


/* ============================================================
   2. HEADER STRIP — display name (kept minimal so QR + games own
   the visual budget).
   ============================================================ */
header.top {
  position: relative;
  z-index: 2;
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 1.4vh 2.5vw;
  gap: 1vw;
}
header.top h1 {
  font-family: var(--gameu-font-display);
  font-size: clamp(1.4rem, 4vh, 3rem);
  margin: 0;
  line-height: 1;
  color: var(--gameu-navy);
  -webkit-text-stroke: 0.6px var(--gameu-navy);
  text-shadow: 3px 3px 0 var(--gameu-yellow);
}
header.top a {
  background: var(--gameu-navy);
  color: var(--gameu-yellow);
  border: 4px solid var(--gameu-yellow-deep);
  border-radius: 999px;
  padding: 0.7vh 1.4vw;
  font-family: var(--gameu-font-button);
  font-size: clamp(0.85rem, 1.6vh, 1.2rem);
  letter-spacing: 2px;
  text-decoration: none;
  box-shadow: var(--gameu-shadow-sm);
}
header.top a:hover { transform: translate(-1px, -1px); }


/* ============================================================
   3. LOBBY GRID — single stage + peer-strip footer.

   The QR card and the games strip share the same `stage` grid
   area: they overlap, and the depth-swap rules below decide which
   one is forward (large, sharp, interactive) vs back (smaller,
   blurred, dim, non-interactive). The master flips between modes
   from the phone (`ClientMessage::SetMasterView`); the TV class
   binding on `<main class="lobby">` adds `.is-catalog` to flip the
   depth. Default state (no modifier) = QR forward.
   ============================================================ */
main.lobby {
  position: relative;
  z-index: 2;
  min-height: 0;
  overflow: hidden;
  display: grid;
  grid-template-columns: 1fr;
  grid-template-rows: minmax(0, 1fr) auto;
  grid-template-areas:
    "stage"
    "peers";
  row-gap: 1.6vh;
  padding: 1.2vh 2.5vw 1.6vh;
}
.lobby > .peer-strip   { grid-area: peers; }
.lobby > .lobby-left,
.lobby > .lobby-right {
  grid-area: stage;
  min-height: 0;
  /* Smooth depth-swap. transform + opacity + filter are all GPU-
     compositable so the animation stays on the compositor thread
     (no main-thread layout). 520 ms transform gives the QR enough
     air to visibly slide from centre to corner; opacity / filter
     come in shorter so the strip "snaps to focus" rather than
     fading slowly. transform-origin stays at centre-centre in
     BOTH modes — corner-tuck is achieved via translate(), so the
     interpolation is continuous (transform-origin can't animate). */
  transition:
    transform 520ms cubic-bezier(0.16, 1, 0.3, 1),
    opacity 320ms ease,
    filter 320ms ease;
  transform-origin: 50% 50%;
  will-change: transform, opacity, filter;
}

/* QR mode (default — no `.is-catalog` on `.lobby`). The QR card
   sits forward at a contained scale; the games strip sits behind
   at full scale but dimmed/blurred so the catalog is still readable
   at-a-glance (it's the "what can I play here" preview while the
   master is still deciding whether to flip into picker mode).
   Pointer events on the strip are off — a stray tap can't steal
   focus from the QR. */
.lobby > .lobby-left {
  display: grid;
  grid-template-rows: minmax(0, 1fr) auto;
  row-gap: 1.2vh;
  z-index: 10;
  transform: translate(0, 0) scale(0.92);
  opacity: 1;
  filter: none;
  pointer-events: auto;
}
.lobby > .lobby-right {
  display: flex;
  flex-direction: column;
  justify-content: center;
  overflow: hidden;
  z-index: 1;
  transform: translate(0, 0) scale(0.74);
  opacity: 0.45;
  filter: blur(2px);
  pointer-events: none;
}

/* Catalog mode — master flipped to "Choose game". The games strip
   rises to full focus; the QR shrinks AND tucks into the top-right
   corner of the stage in one continuous animation.

   The translate() math: with transform-origin at centre and scale
   factor S, the scaled element's centre is still at the stage
   centre, so its top-right corner sits at +(W/2)·(S) from centre,
   i.e. at (W/2 + S·W/2, H/2 − S·H/2). To slide that corner to the
   stage's top-right (W, 0), the centre must move by
       ΔX = (1 − S)·W/2, ΔY = −(1 − S)·H/2
   which in element-relative percentages is ±(1 − S)/2 · 100%.
   For S = 0.22 → ±39 %. Combining translate + scale in one
   `transform` value keeps the whole motion on a single
   interpolated path: the QR slides up and right while it shrinks,
   instead of snapping to a new origin mid-flight. */
.lobby.is-catalog > .lobby-left {
  z-index: 5;
  transform: translate(39%, -39%) scale(0.22);
  opacity: 0.95;
  filter: none;
  pointer-events: none;
}
.lobby.is-catalog > .lobby-right {
  z-index: 10;
  transform: translate(0, 0) scale(1);
  opacity: 1;
  filter: none;
  pointer-events: auto;
}

/* prefers-reduced-motion: deliberately NOT honoured here. The
   depth-swap motion is brief (520 ms) and GPU-compositable
   (transform / opacity / filter only) — it's well below the
   threshold that triggers vestibular discomfort. Killing it
   entirely (the original Phase B behaviour) hid the swap on
   Chrome DevTools' mobile emulator, which forces reduced-motion
   on by default, and made every phone-test of this feature look
   broken. If a future user complains about motion sickness from
   the mode flip we can add a softer fallback (e.g. opacity-only
   crossfade at 120 ms) rather than silently zeroing the
   transition. For now the swap animates the same way for every
   user. */


/* ============================================================
   4. QR HERO CARD — right column, dominant
   ============================================================ */
.qr-card {
  background: var(--gameu-yellow);
  border: var(--gameu-stroke-bold) solid var(--gameu-navy);
  border-radius: var(--gameu-radius-xl);
  box-shadow: var(--gameu-shadow-lg);
  /* Q190 — tightened padding (was `2vh 1.5vw 2vh`) so the QR
     occupies more of the card. With the inner `.qr` frame chrome
     stripped above, this is essentially the only interior whitespace. */
  padding: 1.2vh 0.8vw;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.8vh;
  min-height: 0;
  text-align: center;
}
/* Headline above the QR — "SCAN TO PLAY!" via ::before pseudo-
   element; cheaper than touching the Leptos QrCard component. */
.qr-card::before {
  content: "SCAN TO PLAY!";
  font-family: var(--gameu-font-display);
  font-size: clamp(1.6rem, 4vh, 3rem);
  line-height: 0.95;
  color: var(--gameu-navy);
  -webkit-text-stroke: 1px var(--gameu-navy);
  transform: rotate(-2deg);
  background: var(--gameu-navy);
  color: var(--gameu-yellow);
  padding: 0.4vh 1.6vw 0;
  border-radius: 14px;
  flex-shrink: 0;
}
.qr {
  /* Q190 — `.qr` no longer has an "inner frame" (white background +
     navy border + padding + shadow). The QR SVG itself emits white
     <rect> + navy <path> elements (see `.qr svg path/rect` rules
     below) so the code is self-contained — the wrapping frame was
     decorative chrome that ate ~12% of the card area without adding
     scannability. Stripping it lets the QR fill the full card
     content box, making it noticeably larger at any viewport. */
  flex: 1 1 auto;
  min-height: 0;
  width: 100%;
  max-width: 100%;
  display: flex;
  align-items: stretch;
  justify-content: stretch;
  aspect-ratio: 1 / 1;
  box-sizing: border-box;
}
.qr svg {
  /* Fill the white frame entirely. The earlier `width:auto/height:
   * auto + max-width:100%` rule let the SVG's intrinsic viewBox
   * size win, leaving the QR a small square in a big white area. */
  width: 100%;
  height: 100%;
  display: block;
  image-rendering: pixelated;
}
.qr svg path { fill: var(--gameu-navy) !important; }
.qr svg rect { fill: #fff !important; }
.url, .pair-url {
  display: inline-block;
  margin: 0;
  background: var(--gameu-navy);
  color: var(--gameu-yellow);
  border: 2px solid var(--gameu-yellow-deep);
  border-radius: 999px;
  /* Q190 — URL pill at ~30% of pre-Q190 visual size. The URL is
     shown as a typing fallback for scanners that fail; it doesn't
     need to be readable from couch-distance, only legible up
     close. Shrinking it gives the QR (flex:1 1 auto) ~5vh of
     additional vertical room to grow into within the qr-card. */
  padding: 0.25vh 0.5vw;
  font-family: var(--gameu-font-button);
  font-size: clamp(0.3rem, 0.55vh, 0.45rem);
  letter-spacing: 1px;
  word-break: break-all;
  max-width: 100%;
  box-shadow: var(--gameu-shadow-sm);
}
.url code, .pair-url code {
  background: transparent;
  color: inherit;
  padding: 0;
  font-family: inherit;
  font-weight: inherit;
}

/* The LobbyInfoCard's static "how the lobby works" copy is now
   redundant with the QR card's headline + the peer strip — hide
   it rather than restyle. Q182 keeps the Leptos component intact
   so we can re-enable it cheaply if we change our minds. */
.info-card { display: none !important; }


/* ============================================================
   5. NETIF DIAGNOSTIC — stays scoped under the QR; restyle if
   it ever surfaces (loopback URL only).
   ============================================================ */
.netif-diag {
  background: var(--gameu-yellow-deep);
  color: var(--gameu-navy);
  border: 4px solid var(--gameu-navy);
  border-radius: 12px;
  padding: 0.6vh 0.8vw;
  font-size: clamp(0.7rem, 1.3vh, 1rem);
}
.netif-diag ul { list-style: none; padding: 0; margin: 0.4vh 0 0; }
.netif-diag li { font-family: ui-monospace, Menlo, monospace; }
.netif-err { color: var(--gameu-red); margin: 0.4vh 0 0; }


/* ============================================================
   6. GAMES CAROUSEL (Q183) — left column, dominant action surface
   Single horizontal row, scroll-snapped, master-driven via picker
   focus. Replaces the wrapping grid that used to cut row 2 in half
   once n_games > visible-count.

   Sizing: ~4 tiles + ~½ peek visible at 1080p TV (60–65vw container).
   tile width clamp gives ~13.5vw at 1080p which lands 4 full + ~½
   peek with the 1.6vw gap between tiles; floors at 160px on narrow
   viewports and caps at 320px so the catalog still feels like a
   catalog at 4K. The peek tells the master "more this way" without
   a separate arrow widget.

   Edge clamp lives in the host engine (compute_nav_index Q183) — the
   carousel CSS itself is direction-agnostic.
   ============================================================ */
/* ============================================================
   Q190 iter (water-smooth) — track-translate carousel.

   Architecture:

     <section.game-tiles>     <- viewport (overflow: hidden)
       <div.game-tiles__track>  <- the sliding row (transform: translateX)
         <div.game-tile>        <- N tiles in flex row
         ...
       </div>
     </section>

   Behaviour:
     - The TRACK translates as a single unit so the focused tile
       lands at the viewport's horizontal centre. ONE element, ONE
       transform, ONE transition. The browser interpolates a single
       compositor-layer translate — that's as smooth as DOM
       animation gets.
     - The INDICATOR (the `.game-tiles::before` pseudo) is FIXED at
       the viewport centre. It does not move when focus changes.
       Visual effect: tiles glide past under a stationary yellow
       indicator. Apple-TV pattern.
     - Pre-Q190-iter we had two motions (CSS transform on indicator
       + browser-native smooth-scroll on the carousel). They had
       different easings + durations and could never be made truly
       synced — that's the "jumpy" feeling the user reported. One
       motion has nothing to desync against.

   Edge tiles (focused == 0 or focused == N-1) leave empty space on
   one side of the row. This is the same trade-off Apple TV makes —
   the centred-focus invariant is more important than filling the
   row at the edges, because the focused position is ALWAYS the
   visual anchor the user is reading.
   ============================================================ */
.game-tiles {
  /* Tile sizing tokens — read by both the track (for translate
     math) and per-tile (for flex-basis + aspect-ratio). */
  /* Q190 — tiles fit the available cell, RELATIVE to remaining
     space. Cell width = container's inline-size / total-cols
     (minus gaps + padding); cell height = container's block-size
     / 2 (minus row-gap + padding). Tile-h is the SMALLER of:
       - height-bound: avail-h (so two rows always fit, no clip)
       - width-bound from aspect: avail-cell-w * (aspect-h/aspect-w)
         (so the tile doesn't exceed the cell width)
     Width is then derived back from tile-h via the aspect.
     `min()` picks whichever bound is tighter — this is the
     "fit-within-box" math expressed in pure CSS.

     Aspect 5:4 (was 2:3 portrait): slightly LANDSCAPE so tiles
     are noticeably wider AND larger than the previous 2:3
     portrait. The 5:3 SVG art fits 5:4 with minimal cropping (a
     thin top/bottom strip vs the heavy side-cropping that 2:3
     was doing). */
  /* Sizing constants rebalanced after the single-stage layout
     change: previously the carousel shared a row with a 21vw QR
     column; it now occupies the full stage width, so padding +
     gap come down and the tiles grow to fill the new room.
     Effective tile-w gain at 1080p: ~22% larger than the pre-
     single-stage values (was --tile-pad-block: 14px / --tile-pad-
     inline: 0.6vw / --tile-gap: 1.6vw / --row-gap: 1.6vh). */
  --row-gap: 1.1vh;
  --tile-pad-block: 8px;
  --tile-pad-inline: 0.35vw;
  --tile-aspect: 5 / 4;
  --tile-gap: 1.1vw;
  --avail-cell-w: calc(
    (100cqi - 2 * var(--tile-pad-inline) - (var(--total-cols, 3) - 1) * var(--tile-gap))
    / var(--total-cols, 3)
  );
  --avail-cell-h: calc(
    (100cqb - var(--row-gap) - 2 * var(--tile-pad-block)) / 2
  );
  --tile-h-from-w: calc(var(--avail-cell-w) * 4 / 5);
  --tile-h: min(var(--avail-cell-h), var(--tile-h-from-w));
  --tile-w: calc(var(--tile-h) * 5 / 4);
  --tile-stride: calc(var(--tile-w) + var(--tile-gap));
  --row-stride: calc(var(--tile-h) + var(--row-gap));
  /* Single source of truth for carousel motion — the track and any
     other element that wants to move in lockstep should use these. */
  --motion-duration: 0.42s;
  --motion-easing: cubic-bezier(0.32, 0.72, 0, 1);

  position: relative;
  /* Fill available height so the tile-sizing math (`100cqb / 2`)
     has something definite to derive from. The grid row this
     element sits in is `1fr` of the lobby grid → bounded height. */
  height: 100%;
  /* Viewport: clip the track at the edges. NO native scroll. */
  overflow: hidden;
  /* Q190 — `container-type: size` (was `inline-size`). Now `cqb`
     (block-size) AND `cqi` (inline-size) both resolve against
     this element. The track uses `cqi` for horizontal centring;
     the tile-sizing math uses `cqb` to derive --tile-h from the
     available height (so two rows fit without clipping at any
     viewport). */
  container-type: size;
}

.game-tiles__track {
  /* Q190 — 2-row carousel. `flex-wrap: wrap` plus an explicit
     track width = exactly `--total-cols` columns wide forces the
     first `cols` tiles into the top row and the rest into the
     bottom row. Same partition the engine's compute_nav_index
     uses (idx < cols → top, else bottom) so the visual layout
     matches the navigation model perfectly.
     `--total-cols` is set as inline style on `.game-tiles` by
     Leptos (= ceil(catalog_len / 2)). */
  display: flex;
  flex-direction: row;
  flex-wrap: wrap;
  align-content: flex-start;
  gap: var(--row-gap) var(--tile-gap);
  padding: var(--tile-pad-block) var(--tile-pad-inline);
  /* Track width = total tiles in a row + gaps + the surrounding
     padding-inline. With this exact width, the first `cols` tiles
     fill row 1; tile `cols` is the first to wrap to row 2. */
  width: calc(
    var(--total-cols, 3) * var(--tile-w)
    + (var(--total-cols, 3) - 1) * var(--tile-gap)
    + 2 * var(--tile-pad-inline)
  );
  position: relative;
  /* Track centring driven by the focused COLUMN (Q190 2-row
     update — was `--focused-index` for single-row). Up/Down only
     change `--focused-row`, so the track does NOT move on
     vertical navigation. Left/Right change `--focused-col`, so
     the track translates horizontally to keep the focused column
     under the indicator.
     `50cqi` (not `50%`): translate-percentages resolve against
     the ELEMENT itself, but the track is `max-content` wide
     (much bigger than the carousel viewport). `cqi` resolves
     against `.game-tiles`'s inline-size, which IS the viewport. */
  transform: translateX(calc(
    50cqi
    - var(--tile-pad-inline)
    - var(--focused-col, 0) * var(--tile-stride)
    - var(--tile-w) / 2
  ));
  transition: transform var(--motion-duration) var(--motion-easing);
  /* `will-change: transform` promotes the track to its own
     compositor layer ahead of time so the first transition doesn't
     pay the layer-creation cost mid-flight. */
  will-change: transform;
}

/* Cold-load: NO override. The track always uses the focused-index
   formula above. With the default `var(--focused-index, 0)` = 0,
   tile 0 is centred from the very first paint. When the first peer
   pairs, `is-tracking` flips on and the indicator FADES in over
   tile 0 — but the track does NOT move (tile 0 was already
   centred). Every subsequent swipe is a small adjacent-tile glide.
   Pre-Q190-iter we had a `transform: translateX(0)` override
   here. It made tiles read left-to-right pre-pairing, but the
   moment pairing happened the track had to travel ≈ half a
   viewport to centre tile 0 — visible as a long "jump" even with
   the transition firing. Removing the override eliminates that
   long traversal. */

/* ============================================================
   The focus indicator: a stationary yellow border + glow at the
   parent's centre. Tiles glide past it as the track translates.
   The only animations on this element are opacity (in/out) and
   the breathing keyframe. NO transform animation — it does not
   need to move; the track moves underneath.
   ============================================================ */
/* The indicator lives on the TRACK pseudo, not on `.game-tiles`,
   so it shares the track's coordinate system. `top` is keyed to
   `--focused-row` (0 or 1), `left` is keyed to `--focused-col`.
   Since flex-wrap places tile i at position
       (row=floor(i/cols), col=i%cols [for top] OR i-cols [for bottom])
   in the same coords, the indicator and the focused tile pixel-
   align by construction. */
.game-tiles__track::before {
  content: "";
  position: absolute;
  box-sizing: border-box;
  top: calc(
    var(--tile-pad-block)
    + var(--focused-row, 0) * var(--row-stride)
  );
  left: calc(
    var(--tile-pad-inline)
    + var(--focused-col, 0) * var(--tile-stride)
  );
  width: var(--tile-w);
  aspect-ratio: var(--tile-aspect);
  border: 5px solid var(--gameu-yellow);
  border-radius: var(--gameu-radius-md);
  background: transparent;
  pointer-events: none;
  z-index: 2;
  opacity: 0;
  /* Three things transition with the same duration + easing:
       - top  (row change → indicator slides up/down)
       - left (col change → indicator slides left/right within
               track coords; combined with the track's translate
               this nets to "indicator stays at viewport centre,
               tiles slide under it")
       - opacity (focus arrives / departs) */
  transition: top    var(--motion-duration) var(--motion-easing),
              left   var(--motion-duration) var(--motion-easing),
              opacity 0.18s ease;
  box-shadow:
    0 0 32px rgba(255, 235, 89, 0.55),
    0 0 0 9px rgba(255, 235, 89, 0.18),
    inset 0 0 16px rgba(255, 235, 89, 0.20);
}
.game-tiles.is-tracking .game-tiles__track::before {
  opacity: 1;
  /* Breathing glow keeps the focus signal alive at idle. Runs
     continuously (1.6s loop) and is independent of any other
     motion — when the track glides, the breathing continues at
     its own rhythm. */
  animation: indicator-breathe 1.6s ease-in-out infinite;
}

@keyframes indicator-breathe {
  0%, 100% {
    box-shadow:
      0 0 24px rgba(255, 235, 89, 0.45),
      0 0 0 6px rgba(255, 235, 89, 0.14),
      inset 0 0 14px rgba(255, 235, 89, 0.18);
  }
  50% {
    box-shadow:
      0 0 40px rgba(255, 235, 89, 0.78),
      0 0 0 11px rgba(255, 235, 89, 0.24),
      inset 0 0 20px rgba(255, 235, 89, 0.30);
  }
}

.game-tile {
  /* Q190 — `box-sizing: border-box` so the navy stroke is included
     in --tile-w. The sliding focus indicator (`.game-tiles::before`)
     uses the same model; the two share dimensions exactly. */
  box-sizing: border-box;
  flex: 0 0 var(--tile-w);
  /* Q190 — portrait aspect-ratio (taller-than-wide). The variable
     is set once on `.game-tiles` so the tile and indicator stay
     in lockstep if the aspect changes. */
  aspect-ratio: var(--tile-aspect);
  position: relative;
  display: flex;
  flex-direction: column;
  background: var(--gameu-yellow);
  color: var(--gameu-navy);
  border: var(--gameu-stroke) solid var(--gameu-navy);
  border-radius: var(--gameu-radius-md);
  overflow: hidden;
  text-decoration: none;
  /* Q188 — layered shadows replace the single hard-navy box-shadow:
       1. signature gameu hard-navy offset (preserved from Q183 — this
          is what makes a tile look like a *gameu* tile)
       2. ambient soft drop for depth — gives the row a 3D feel
          against the halftone bg without overpowering the hard shadow
       3. inset top highlight — subtle bright lip catches the eye and
          makes the tile look "filled" rather than flat
     The 0.15s transition still applies; hover lifts the whole stack
     in unison, and the focus ring (Q188 focus-glow animation below)
     adds a fourth layer when active. */
  box-shadow:
    6px 6px 0 var(--gameu-navy),
    0 12px 24px rgba(17, 23, 63, 0.18),
    inset 0 2px 0 rgba(255, 255, 255, 0.18);
  transition: transform 0.15s ease, box-shadow 0.15s ease;
  cursor: pointer;
  /* Q188 — entrance cascade. Each tile slides in from the right with
     a stagger (animation-delay set per nth-child below). animation-
     fill-mode:backwards holds the pre-animation pose until the
     delay expires, so the row paints its final layout instantly
     and tiles "arrive" into their slots one after another. The
     animation runs once at mount; keyed <For> means subsequent
     re-renders preserve element identity (no re-trigger). New
     tiles added later (e.g. a freshly installed game) get their
     own entrance flourish — that's a feature, draws the eye to
     the new entry. */
  /* Q189-H: scope the entrance cascade so it only fires while the
     carousel container has NOT yet been marked `.is-entered`. The
     Leptos GameTileGrid component flips that class on the parent
     `<section.game-tiles>` once per session (via NodeRef + a single-
     fire Effect), so the cascade plays exactly on cold lobby paint.
     Subsequent state changes (peer joins, picker_focus shifts,
     install completions) re-render the parent closure but the
     `.is-entered` class is still set, so this animation rule no
     longer matches and tiles paint in their final position
     instantly. Without this scope the cascade would re-fire on
     every Leptos re-render of the lobby view. */
}
.game-tiles:not(.is-entered) .game-tile {
  animation: tile-enter 0.5s cubic-bezier(0.22, 0.61, 0.36, 1) backwards;
}
/* Per-position fill rotation — same nth-child idea as before but
   using the theme palette. The font color flips to white where
   the fill is dark for legibility. The same nth-child pattern
   doubles as the entrance-cascade timing source: each tile waits
   slightly longer than the previous one before sliding in. */
.game-tile:nth-child(4n+1) { background: var(--gameu-red);    color: var(--gameu-white); }
.game-tile:nth-child(4n+2) { background: var(--gameu-yellow); color: var(--gameu-navy); }
.game-tile:nth-child(4n+3) { background: var(--gameu-green);  color: var(--gameu-white); }
.game-tile:nth-child(4n)   { background: var(--gameu-orange); color: var(--gameu-white); }
.game-tiles:not(.is-entered) .game-tile:nth-child(1) { animation-delay: 0.04s; }
.game-tiles:not(.is-entered) .game-tile:nth-child(2) { animation-delay: 0.10s; }
.game-tiles:not(.is-entered) .game-tile:nth-child(3) { animation-delay: 0.16s; }
.game-tiles:not(.is-entered) .game-tile:nth-child(4) { animation-delay: 0.22s; }
.game-tiles:not(.is-entered) .game-tile:nth-child(5) { animation-delay: 0.28s; }
.game-tiles:not(.is-entered) .game-tile:nth-child(6) { animation-delay: 0.34s; }
.game-tiles:not(.is-entered) .game-tile:nth-child(7) { animation-delay: 0.40s; }
.game-tiles:not(.is-entered) .game-tile:nth-child(8) { animation-delay: 0.46s; }
.game-tiles:not(.is-entered) .game-tile:nth-child(9) { animation-delay: 0.52s; }
.game-tiles:not(.is-entered) .game-tile:nth-child(n+10) { animation-delay: 0.58s; }

@keyframes tile-enter {
  0% {
    opacity: 0;
    transform: translateX(36px) scale(0.94);
  }
  60% {
    opacity: 1;
  }
  100% {
    opacity: 1;
    transform: translateX(0) scale(1);
  }
}

/* Q190 — `.game-tile:hover` rule REMOVED. The carousel slides
   under the cursor during swipe transitions, so as tiles pass
   under a stationary mouse pointer each one momentarily fires
   `:hover` and lifted/scaled. Visually that read as "random tiles
   selecting themselves" while swiping — even though it was just
   the cursor catching tiles in flight. The focus signal is now
   the sliding `.game-tiles__track::before` indicator (yellow
   border + glow); no per-tile hover transform is needed. The
   target surface (TV with phone-as-input) has no cursor anyway —
   `:hover` was only firing during desktop dev. */

.game-tile.is-focused {
  /* Q190 — focus signal is now the sliding indicator on
     `.game-tiles::before`, not per-tile motion. The tile itself
     stays static so multiple tiles can't fight each other's
     transforms and so nothing extends past the parent's
     `overflow-y: hidden` clip. Outline removed for the same
     reason — the indicator's yellow border + glow IS the focus
     ring. */
  outline: none;
}

/* Respect prefers-reduced-motion for DECORATIVE motion only. The
   track's translate transition is ESSENTIAL UX — without it the
   focused-tile centring is invisible to the user and the picker
   feels broken — so it stays on regardless. Apple TV / tvOS does
   the same: reduced-motion kills auto-play, parallax, and ambient
   pulses, but the focus-tracking row still slides.
   The cascade-on-mount and the breathing-glow keyframe are pure
   eye candy and ARE silenced under reduce. */
@media (prefers-reduced-motion: reduce) {
  .game-tiles:not(.is-entered) .game-tile {
    animation: none;
  }
  .game-tiles.is-tracking .game-tiles__track::before {
    animation: none;
  }
}

.game-tile-thumb,
.game-tile-fallback,
.game-tile img {
  width: 100%;
  /* Q190: tile parent has `aspect-ratio: 5/3`; thumb fills the
     space remaining above the label via `flex: 1 1 0`. SVG art
     cover-fits into that box. The label takes ~3vh of natural
     height, so the thumb is a nearly-5:3 region — close enough
     to the art's native aspect that cover-fit doesn't crop. */
  flex: 1 1 0;
  display: block;
  background: #fff;
}
.game-tile-thumb {
  background-size: cover;
  background-position: center;
  border-bottom: 5px solid var(--gameu-navy);
}
.game-tile-thumb:not([style]) { display: none; }
.game-tile-thumb[style] + .game-tile-fallback { display: none !important; }
.game-tile-fallback {
  display: none;
  align-items: center;
  justify-content: center;
  font-family: var(--gameu-font-display);
  font-size: clamp(1rem, 1.8vh, 1.4rem);
  color: var(--gameu-navy);
  background: #fff;
  text-align: center;
  padding: 0.4vh 0.6vw;
  box-sizing: border-box;
  word-break: break-word;
  border-bottom: 5px solid var(--gameu-navy);
}
.game-tile-label {
  padding: 1.2vh 0.8vw;
  font-family: var(--gameu-font-display);
  font-size: clamp(1rem, 2vh, 1.5rem);
  text-align: center;
  word-break: break-word;
  -webkit-text-stroke: 0.4px currentColor;
}


/* ============================================================
   7. TILE BADGES (Get / Installing / Failed) → use theme pills
   ============================================================ */
.tile-badge {
  display: inline-block;
  margin: 0.3vh auto 0.6vh;
  padding: 0.4vh 0.9vw;
  border-radius: 999px;
  border: 3px solid var(--gameu-navy);
  font-family: var(--gameu-font-button);
  font-size: clamp(0.7rem, 1.3vh, 1rem);
  letter-spacing: 2px;
}
.tile-badge--get         { background: var(--gameu-navy);     color: var(--gameu-yellow); border-color: var(--gameu-yellow-deep); }
.tile-badge--unsupported { background: rgba(0,0,0,0.5);       color: #fff; border-color: var(--gameu-navy); }
.tile-badge--installing  { background: var(--gameu-yellow);   color: var(--gameu-navy);  animation: tile-badge-pulse 1.2s ease-in-out infinite; }
.tile-badge--failed      { background: var(--gameu-red);      color: #fff; border-color: var(--gameu-navy); }
@keyframes tile-badge-pulse {
  0%, 100% { opacity: 1; }
  50%      { opacity: 0.55; }
}


/* ============================================================
   8. PEER STRIP — bottom of the lobby, full width
   ============================================================ */
.peer-strip {
  background: var(--gameu-yellow);
  border: var(--gameu-stroke) solid var(--gameu-navy);
  border-radius: var(--gameu-radius-lg);
  box-shadow: var(--gameu-shadow-md);
  padding: 1vh 1.4vw;
  min-height: 11vh;
  display: flex;
  align-items: center;
  gap: 1vw;
  overflow-x: auto;
}
.peer-strip-row {
  display: flex;
  flex-wrap: nowrap;
  gap: 1vw;
  align-items: center;
}
.peer-strip-empty {
  display: inline-flex;
  align-items: center;
  gap: 0.8vw;
  background: rgba(17,23,63,0.10);
  border: 4px dashed var(--gameu-navy);
  border-radius: 24px;
  padding: 0.8vh 1.2vw;
  font-family: var(--gameu-font-button);
  font-size: clamp(0.85rem, 1.6vh, 1.2rem);
  letter-spacing: 2px;
  color: var(--gameu-navy);
}
.peer-circle-empty,
.peer-circle-pending {
  width: 5vh;
  height: 5vh;
  border-radius: 50%;
  border: 0.4vh dashed var(--gameu-navy);
  background: rgba(17,23,63,0.06);
}
.peer-circle-pending {
  animation: peer-pending-pulse 1.4s ease-in-out infinite;
}
@keyframes peer-pending-pulse {
  0%, 100% { opacity: 0.55; transform: scale(1); }
  50%      { opacity: 1.0;  transform: scale(1.06); }
}
.peer-label-pending {
  color: var(--gameu-navy);
  font-style: normal;
  opacity: 0.6;
  font-family: var(--gameu-font-button);
  letter-spacing: 2px;
}
.peer-avatar {
  flex-shrink: 0;
  position: relative;
  background: var(--gameu-navy);
  color: var(--gameu-white);
  border: 4px solid var(--gameu-yellow-deep);
  border-radius: 999px;
  padding: 0.6vh 1vw 0.6vh 0.6vh;
  display: flex;
  align-items: center;
  gap: 0.6vw;
  font-family: var(--gameu-font-display);
  font-size: clamp(0.85rem, 1.6vh, 1.2rem);
  box-shadow: var(--gameu-shadow-sm);
}
.peer-circle {
  width: 5vh;
  height: 5vh;
  border-radius: 50%;
  border: 3px solid var(--gameu-navy);
  flex-shrink: 0;
  display: flex;
  align-items: center;
  justify-content: center;
}
.peer-label {
  font-weight: 700;
  max-width: 16vh;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.peer-crown {
  position: absolute;
  top: -1vh;
  right: -0.4vw;
  font-size: 2.2vh;
  transform: rotate(-15deg);
  filter: drop-shadow(0 1px 0 var(--gameu-navy));
}


/* ============================================================
   9. FOOTER STATUS — minimal navy chip strip
   ============================================================ */
footer.status {
  position: relative;
  z-index: 2;
  display: flex;
  justify-content: space-between;
  gap: 1vw;
  padding: 0.5vh 2.5vw 0.7vh;
  font-family: var(--gameu-font-button);
  font-size: clamp(0.7rem, 1.2vh, 1rem);
  letter-spacing: 1.5px;
  color: var(--gameu-navy);
  opacity: 0.7;
}


/* ============================================================
   10. INLINE CODE in any leftover paragraph — pill it.
   ============================================================ */
code {
  background: var(--gameu-navy);
  color: var(--gameu-yellow);
  padding: 0.1em 0.45em;
  border-radius: 0.6vh;
  font-weight: 600;
  font-family: var(--gameu-font-button);
  letter-spacing: 1px;
}


/* ============================================================
   11. GAME TRANSITION OVERLAY (Q189) — fade-to-navy on launch / exit
   ============================================================
   Reactive to the LobbyScreen variants (Launching / Exiting) the
   host pushes via LobbyState. The Leptos `GameTransitionOverlay`
   component flips the modifier classes on the same root element so
   transitions never tear down + rebuild the overlay node — the
   browser keeps the same compositor layer and the animation runs
   smoothly.

   Three states:
     1. Hidden (Home / InGame steady state). Pointer-events off,
        opacity 0, no animation. The overlay node still exists in
        the DOM so its compositor layer doesn't churn between
        transitions.
     2. Opening (LobbyScreen::Launching). Opacity 0 → 1 over
        LAUNCHING_DURATION_MS (600ms) so the iframe load is
        masked.
     3. Closing (LobbyScreen::Exiting). Opacity 1 → 0 over
        EXITING_DURATION_MS (400ms) so Home reappears smoothly.

   Animation durations match the reducer's clock so the overlay
   reaches full opacity exactly as Launching → InGame fires (the
   tick advance + the CSS animation completion are both pinned to
   the same wall-clock values from gameu-protocol).

   z-index above lobby chrome but below tv-toast-overlay (10000)
   so toasts during transitions are still visible. */
.gameu-game-transition {
  position: fixed;
  inset: 0;
  z-index: 9000;
  background: #000;
  pointer-events: none;
  /* Q190 iris transitions — the overlay is BLACK and a `clip-path:
     circle(...)` reveals or hides it. No `opacity` involved: the
     black either covers the screen (radius 120%) or doesn't exist
     visibly (radius 0%). Default state = invisible. */
  clip-path: circle(0% at center);
}

.gameu-game-transition.is-opening {
  /* Launch — iris CLOSES IN: black circle grows from a centre dot
     to fill the screen, covering the lobby as the iframe loads
     behind it. 1000ms matches LAUNCHING_DURATION_MS so the iris
     reaches full coverage exactly as the tick pump auto-advances
     Launching → InGame. ease-in feels like a deliberate ramp into
     full-cover; ease-out felt like a wipe. */
  animation: gameu-iris-close 1000ms ease-in forwards;
}

.gameu-game-transition.is-closing {
  /* Exit — iris OPENS OUT: black circle shrinks from full-screen
     to a centre dot, revealing the lobby. 1200ms matches
     EXITING_DURATION_MS — long enough for the "Game ending…"
     message to register before the iris collapses. ease-out so
     most of the visibility-reveal happens in the back half. */
  animation: gameu-iris-open 1200ms ease-out forwards;
}

/* Note on radii: `clip-path: circle(R%)` has 100% = closest-side,
   so on a 16:9 viewport (closest-side = half-height) you need
   ~200% just to reach the corners. We use 200% as the "fully
   covered" pose; anything less leaves the corners visible.
   Note on focal point: `at var(--iris-x, 50%) var(--iris-y, 50%)`
   pulls the centre from inline-style CSS variables that Leptos's
   `GameTransitionOverlay` Effect writes from the focused tile's
   bounding rect. Default 50%/50% (viewport centre) when no tile
   coords are available — matches the pre-Q190 behaviour. */
@keyframes gameu-iris-close {
  0%   { clip-path: circle(0%   at var(--iris-x, 50%) var(--iris-y, 50%)); }
  100% { clip-path: circle(200% at var(--iris-x, 50%) var(--iris-y, 50%)); }
}

@keyframes gameu-iris-open {
  0%   { clip-path: circle(200% at var(--iris-x, 50%) var(--iris-y, 50%)); }
  /* Hold near full-coverage for the first half so the "Game
     ending…" message stays clearly readable, then collapse. The
     iris is barely shrinking during the hold (200% → 180% — both
     still cover all corners on any reasonable aspect ratio), so
     visually nothing changes; only after t=50% does the iris
     start visibly closing. */
  50%  { clip-path: circle(180% at var(--iris-x, 50%) var(--iris-y, 50%)); }
  100% { clip-path: circle(0%   at var(--iris-x, 50%) var(--iris-y, 50%)); }
}

/* Q190 — message text rendered inside the overlay. Centered, large
   and yellow against the navy backdrop. Renders on every state but
   the parent's opacity gate (defaulting to 0 outside transitions)
   keeps it invisible during steady-state Home / InGame. */
.gameu-game-transition__inner {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: var(--gameu-font-display);
  font-size: clamp(36px, 6vw, 96px);
  color: var(--gameu-yellow);
  text-transform: uppercase;
  letter-spacing: 0.08em;
  text-shadow: 0 4px 0 rgba(17, 23, 63, 0.6);
  pointer-events: none;
  /* The text fades in slightly behind the backdrop's opacity so it
     doesn't pop in awkwardly at t=0 when the backdrop is still
     translucent. Same duration as the parent so they finish
     together. */
}
.gameu-game-transition.is-opening .gameu-game-transition__inner {
  animation: gameu-game-overlay-text-in 1000ms ease-in-out forwards;
}
.gameu-game-transition.is-closing .gameu-game-transition__inner {
  animation: gameu-game-overlay-text-in 1200ms ease-in-out forwards;
}

@keyframes gameu-game-overlay-text-in {
  0%   { opacity: 0; transform: scale(0.92); }
  20%  { opacity: 1; transform: scale(1); }
  80%  { opacity: 1; transform: scale(1); }
  100% { opacity: 1; transform: scale(1); }
}

/* Reduced-motion: keep the overlay + message visible but skip the
   iris animation — snap to full-cover instead. Users who opt out
   of motion still get the visual masking AND the textual message
   (so the user knows the master's request was honoured); they
   just don't get the iris kinetics. */
@media (prefers-reduced-motion: reduce) {
  .gameu-game-transition.is-opening,
  .gameu-game-transition.is-closing {
    animation: none;
    clip-path: circle(200% at var(--iris-x, 50%) var(--iris-y, 50%));
  }
  .gameu-game-transition.is-opening .gameu-game-transition__inner,
  .gameu-game-transition.is-closing .gameu-game-transition__inner {
    animation: none;
    opacity: 1;
    transform: none;
  }
}


/* ============================================================
   12. TV TOAST OVERLAY — keep, light theme tweak
   ============================================================ */
.tv-toast-overlay {
  position: fixed;
  top: 1.5vh;
  right: 1.5vw;
  display: flex;
  flex-direction: column;
  gap: 0.6vh;
  z-index: 10000;
  pointer-events: none;
  max-width: min(28vw, 420px);
}
.tv-toast {
  background: var(--gameu-navy);
  color: var(--gameu-white);
  border: 4px solid var(--gameu-yellow-deep);
  border-radius: 16px;
  padding: 0.9vh 1.2vw;
  font-family: var(--gameu-font-display);
  font-size: clamp(0.95rem, 1.6vh, 1.2rem);
  line-height: 1.3;
  box-shadow: var(--gameu-shadow-md);
  animation: tv-toast-in 180ms ease-out;
}
.tv-toast-info    { border-color: #58a6ff; }
.tv-toast-success { border-color: var(--gameu-green); }
.tv-toast-warning { border-color: var(--gameu-yellow-deep); }
.tv-toast-error   { border-color: var(--gameu-red); }
.tv-toast-prefix,
.tv-toast-source { font-weight: 600; margin-right: 0.2em; opacity: 0.85; font-family: var(--gameu-font-button); letter-spacing: 1.5px; }
.tv-toast-body   { opacity: 0.95; }
@keyframes tv-toast-in {
  from { opacity: 0; transform: translateY(-6px); }
  to   { opacity: 1; transform: translateY(0); }
}
