@import url("tokens.css");

/* Shared DOM lobby styles. Source of truth: web/lobby/lobby.css - the copy inside a game's
   WebGL template is written by tools/sync-lobby.sh. Per-game colours arrive as --lobby-*
   custom properties set on <html> from LOBBY_CONFIG.palette (see lobby.js applyPalette).

   Self-hosted on purpose: no CDN, so the page still renders off Cloudflare static assets
   (or a warm cache) with no third-party request. Display faces only - body copy stays on the
   system stack, so nothing load-bearing waits on a font that may still be swapping in.
   (Press Start 2P was the wordmark face 2026-08-18 -> 2026-08-23; Unbounded replaced it for
   every game per user direction.) */
/* Unbounded, the astroball.io homepage's display face and THE display face for every game -
   wordmark, team labels, CTAs. Its @font-face now lives in the tokens.css imported above, one
   declaration for every surface on the origin; the woff2 still ships beside this file. */

/* The results screen's two faces, self-hosted for the same reason (latin subset, from Google
   Fonts' own woff2). Inter is the variable roll, so one file covers 500 -> 900; Montserrat is
   the static 900 italic and nothing else, because the display face is only ever used for the
   team names and the score numerals. Both are scoped to .lobby-results below - the rest of
   the lobby keeps the system stack, so no other phase waits on a font. */
@font-face {
  font-family: "Inter";
  font-style: normal;
  font-weight: 100 900;
  font-display: swap;
  src: url("Inter-latin.woff2") format("woff2");
}

@font-face {
  font-family: "Montserrat";
  font-style: italic;
  font-weight: 900;
  font-display: swap;
  src: url("Montserrat-Italic900-latin.woff2") format("woff2");
}

:root {
  --lobby-bg: #04060d;
  --lobby-panel: rgba(18, 19, 30, 0.72);
  --lobby-panel-border: rgba(255, 255, 255, 0.08);
  /* ONE house theme for every game (user direction 2026-08-23): the lobby always wears the
     astroball.io colours - accent, button gradient, backdrop. Games no longer set palette
     colour overrides (the mechanism still works; the 4v4 mock preset exercises it).
     The values below that every surface on the origin shares now come from tokens.css; the
     ones that stay literal here (--lobby-bg, the panel skins, the two dim inks) are the
     lobby's own and deliberately differ from the flat pages'. */
  --lobby-accent: var(--ig-accent);
  --lobby-danger: var(--ig-danger);
  --lobby-text: #f4f6ff;
  --lobby-text-dim: #9aa3c7;
  --lobby-radius: var(--ig-radius);
  /* The overlay's left/right gutter. A token rather than a literal because voice.css has to add
     the voice rail's width to the LEFT one and still land on the same gap as the right - two
     copies of this clamp would drift the moment either is touched. */
  --lobby-pad-x: clamp(20px, 4vw, 56px);
  --lobby-display: var(--ig-display);
  /* The results screen's own pair (S1 "Nebula Halves"): a heavy italic display face for the
     team names and the score numerals, Inter for everything else on that screen. */
  --lobby-results-display: "Montserrat", "Inter", -apple-system, Helvetica, Arial, sans-serif;
  --lobby-results-body: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  /* The plain terminal panel's message slot (contract addendum v1.4, kept in v1.5). It is a
     MIN height: without an `endgame` the panel has only this line and the Play again button,
     so nothing below it can move when a longer message arrives. */
  --lobby-ended-headline-h: 92px;
  /* Width of the results screen's centre column - the score tiles, the team totals and the
     award cards all live in it, and the two team halves take exactly half the rest each. */
  --lobby-results-centre-w: clamp(232px, 23vw, 420px);
  /* ---- the two-column pre-game shell (A1) ----
     The shell is one width for every pre-game phase, the same way the old 520px panel was:
     the header and the two columns never move between boot, idle, connecting and lobby. The
     right column alone is the old panel's job, so --lobby-main-w is what a single-column
     layout (a game with nothing to say in the left column) falls back to. */
  --lobby-shell-w: 1180px;
  --lobby-main-w: 520px;
  --lobby-col-gap: clamp(24px, 3vw, 44px);
  /* A team column's own geometry, shared by the panel, its slots and the drag states. */
  --lobby-team-radius: 22px;
  /* Compact cards (Jordan 2026-08-26, "option B"): the card unit shrinks ~54 -> ~42px so a
     5-row column, the bench, the invite row and Start all clear the fold. Still a clamp, for
     the reason the old one was: a short window gets a shorter card rather than a scrollbar. */
  --lobby-card-radius: 11px;
  --lobby-card-h: clamp(38px, 5.5vh, 44px);
  /* Primary action (.lobby-cta): the homepage's .play pill, and its scaled-up variant. */
  --lobby-cta-h: 52px;
  --lobby-cta-big-h: clamp(58px, 9vh, 72px);
  --lobby-cta-ink: var(--ig-cta-ink);
  /* The astroball.io homepage button component: thick face + a gradient pair. A game that
     wants a single-colour pill just leaves the pair at its accent default. */
  --lobby-cta-font: var(--lobby-display);
  /* The astroball.io homepage's PLAY NOW gradient (red -> violet) - the one button look,
     every game, every screen. */
  --lobby-cta-grad-a: var(--ig-cta-grad-a);
  --lobby-cta-grad-b: var(--ig-cta-grad-b);
  /* Weight for --lobby-display text (title, team labels). Unbounded is a variable face, so
     the thick homepage weight is the default; a single-weight face overrides via palette. */
  --lobby-display-weight: 900;
  /* Whole overlay backdrop as one overridable value: near-black base with the astroball.io
     red/blue glows - the house atmosphere, same on every game, light text well clear of AA.
     Static - nothing here animates, so there is nothing for prefers-reduced-motion to turn
     off. Overridable via LOBBY_CONFIG.palette ({ backdrop: "..." }) like every token, though
     no shipped game does. */
  --lobby-backdrop:
    radial-gradient(58% 42% at 15% 12%, rgba(229, 26, 26, 0.2), transparent 70%),
    radial-gradient(56% 42% at 85% 14%, rgba(0, 102, 255, 0.22), transparent 70%),
    radial-gradient(70% 50% at 50% 88%, rgba(0, 102, 255, 0.1), transparent 74%),
    var(--lobby-bg);
}

/* `align-items: flex-start` IS the composition: the shell is top-anchored in every phase
   (see .lobby-panel), and a shell taller than the viewport starts at the top and scrolls. */
#lobby-overlay {
  position: fixed;
  inset: 0;
  z-index: 20;
  display: flex;
  align-items: flex-start;
  justify-content: center;
  /* Header breathing room trimmed by a third top and bottom (user direction 2026-08-23) -
     the saved height goes to the content below. */
  padding: clamp(13px, 3vh, 27px) var(--lobby-pad-x) 40px;
  box-sizing: border-box;
  overflow-y: auto;
  background: var(--lobby-backdrop);
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  color: var(--lobby-text);
}

#lobby-overlay * {
  box-sizing: border-box;
}

#lobby-overlay.lobby-hidden {
  display: none;
}

/* ---------- page transitions (T1, T2, T3, T5) ----------

   Every "page" the lobby has now arrives instead of appearing: the first paint, each pre-game
   phase change, the departure into the match, and the way back from a results screen. Opacity
   and a 6px rise, nothing else - every element is already in its final box when the animation
   starts (the same rule the results entrance follows), so a transition here can never move
   anything.

   T3, lobby -> playing: the overlay fades OUT. `.lobby-hidden` is added in the same frame the
   fade starts - it is the contract for "the overlay is out of the way" and the match gates read
   it with no slack after Start - so `.lobby-leaving` is what holds the box on screen for the
   length of the fade, and it has to out-specify the display: none above. pointer-events go at
   the FIRST frame: a fade may never delay the canvas becoming clickable. lobby.js wipes the
   panel and drops `.lobby-leaving` when the fade lands. */
#lobby-overlay {
  transition: opacity 220ms ease;
}

#lobby-overlay.lobby-leaving {
  opacity: 0;
  pointer-events: none;
}

#lobby-overlay.lobby-hidden.lobby-leaving {
  display: flex;
}

/* T1/T2/T5, the pre-game panel arriving. lobby.js decides who wears the class: the whole panel
   when the overlay ENTERS panel mode (first paint, and the way back from a results screen), the
   right column alone when only the phase changed inside it - the left aside is identical in
   every pre-game phase, so re-fading it would read as flicker. `both` on purpose: a re-render
   that re-adds the class mid-animation restarts the fade rather than flashing the element in. */
@keyframes lobby-page-in {
  from {
    opacity: 0;
    transform: translateY(6px);
  }
  to {
    opacity: 1;
    transform: none;
  }
}

#lobby-overlay .lobby-page-enter {
  animation: lobby-page-in 260ms ease-out both;
}

/* ---------- T6/T7, the two page fades inside the overlay (CONVEX MODE ONLY) ----------

   lobby.js adds both of these only when the convex lobby is driving the page - with the flag off
   the overlay's markup has to stay byte-identical to the pre-convex lobby, so neither the class
   nor the copy below ever exists there.

   T6: the overlay coming BACK from a match. It was display: none, so there is nothing to
   cross-fade with and no "from" style a transition could start at - an animation is what carries
   opacity from 0 without the element having been rendered a frame earlier. Content mounts at
   once; only opacity moves, so the results screen is readable from the first frame it is asked
   for and its own staggered entrance plays inside this one.

   T7: one page replaced by a different one while the overlay stays up (lobby -> connecting on
   Start, results -> lobby on the way back). renderPanel destroys the outgoing page in the frame
   it builds the new one, so what fades is a still copy pinned over the box the original had:
   fixed (the overlay scrolls and re-lays-out underneath it), inert, and out of flow, so it can
   move nothing. It fades out over the incoming page's own entrance - one cross-fade, not two
   animations on the same content. */
#lobby-overlay.lobby-arriving {
  animation: lobby-overlay-in 600ms ease both;
}

@keyframes lobby-overlay-in {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

#lobby-overlay .lobby-ghost {
  position: fixed;
  z-index: 3;
  pointer-events: none;
  animation: lobby-page-out 520ms ease-out both;
}

@keyframes lobby-page-out {
  from {
    opacity: 1;
  }
  to {
    opacity: 0;
  }
}

/* Minimal veil mode: host-unavailable banner during "playing", game stays visible */
#lobby-overlay.lobby-veil-mode {
  background: transparent;
  padding-top: 24px;
  pointer-events: none;
}

.lobby-veil-banner {
  pointer-events: auto;
  display: flex;
  align-items: center;
  gap: 10px;
  background: rgba(5, 6, 11, 0.9);
  border: 1px solid var(--lobby-panel-border);
  border-radius: 999px;
  padding: 10px 20px;
  /* No backdrop-filter here: blurring over the live WebGL canvas forces a per-frame
     compositor readback of the game's output. Opaque-ish background instead. */
}

/* ...but never while the overlay is on its way out (T3). The veil -> playing exit keeps the
   banner in the DOM for the length of the fade, and the pill's own pointer-events: auto
   out-specifies the inertness `.lobby-leaving` puts on the overlay - which would leave a
   click-swallowing pill hanging over the canvas for the whole fade. */
#lobby-overlay.lobby-leaving .lobby-veil-banner {
  pointer-events: none;
}

/* One width for every phase, so the shell's edges never jump either.

   Top-anchored ALWAYS (user direction 2026-08-23, superseding the brief centred period): the
   overlay's own align-items: flex-start + padding-top clamp place the shell, so every phase
   starts from the same line and content that arrives later extends downward - no vertical
   re-composition between screens, same rule the original 520px panel shipped with. */
.lobby-panel {
  width: 100%;
  max-width: var(--lobby-shell-w);
  display: flex;
  flex-direction: column;
  gap: clamp(15px, 3vh, 32px);
}

/* Header band: REMOVED in contract addendum v1.16 (plans/GLOBAL_HEADER_PLAN.md). The panel used to
   open on a `.lobby-topbar` — the wordmark centred on the shell with the HOME pill, the Leaderboard
   pill, the account chip and the CLAIM HOST pill pinned around it. All five are page chrome, and
   the site has one header for every page now (web/shell/src/SiteHeader.tsx); the only one of them
   that was lobby STATE rather than furniture, claim-host, reaches that header through the
   `ig:lobby-chrome` seam instead (src/lobby-port/lobby/pageChrome.ts). The panel opens on its
   columns, and `.lobby-topbar`, `.lobby-topbar-left`, `.lobby-topbar-right`, `.lobby-home`,
   `.lobby-claim-host`, `.lobby-auth-pill`, `.lobby-leaderboard-link`, `.lobby-head` and
   `.lobby-title` went with the markup that wore them. */

/* The two columns. Left = what the game is (video + how to play), right = what you do next.
   `.lobby-columns-single` is the fallback for a page that supplied neither a video nor any
   HOW TO PLAY copy: the right column alone, centred at the old panel width. */
/* Centred, not top-aligned: the two columns are independent of each other's length - the left
   one is the same height in every phase, the right one is two fields on the entry screen and a
   full roster in the lobby - so anchoring both to the top stranded a quadrant of empty space
   beside whichever was shorter. Centring reads as one composition at every roster size. */
.lobby-columns {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  gap: var(--lobby-col-gap);
  /* Top-aligned on purpose (2026-08-23): the two columns read from the same start line in
     every phase, and the shell itself is top-anchored in the viewport (see .lobby-panel). */
  align-items: start;
}

.lobby-columns-single {
  grid-template-columns: minmax(0, var(--lobby-main-w));
  justify-content: center;
}

.lobby-aside {
  display: flex;
  flex-direction: column;
  gap: clamp(18px, 2.6vh, 26px);
  min-width: 0;
}

/* The left column's 16:9 anchor: LOBBY_CONFIG.gameplayVideo as a <video>, else
   LOBBY_CONFIG.gameplayImage as an <img>. One box either way, so the column composes the
   same whether a game has a loop yet or only a still. A page that supplies neither gets no
   box at all - there is deliberately no placeholder. */
.lobby-media {
  width: 100%;
  aspect-ratio: 16 / 9;
  display: block;
  border-radius: 18px;
  border: 1px solid var(--lobby-panel-border);
  background: #000;
  object-fit: cover;
}

@supports (color: color-mix(in srgb, red 50%, white)) {
  .lobby-media {
    border-color: color-mix(in srgb, var(--lobby-accent) 30%, transparent);
    box-shadow: 0 0 40px color-mix(in srgb, var(--lobby-accent) 14%, transparent),
      0 24px 60px rgba(0, 0, 0, 0.45);
  }
}

/* The game title as a SELECTOR control above the media (WORDY_WATERWAY_PLAN Phase 1 step 0). On a
   game page there is no top-header wordmark any more, so the title rides here — a chevron'd control
   that will become the game switcher (GAME_SWITCH_LOBBY_PLAN's entry point). NON-FUNCTIONAL for
   now: it carries LOBBY_CONFIG.title and no action. It sits ABOVE the media (its own row in the
   aside's flex column), so the gameplay box is never covered. Nothing here names a game. */
.lobby-game-select {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  padding: 10px 14px;
  border-radius: 14px;
  border: 1px solid var(--lobby-panel-border);
  background: rgba(255, 255, 255, 0.05);
  color: var(--lobby-text, #fff);
  text-align: left;
  cursor: pointer;
  font-family: var(--lobby-display);
  font-weight: var(--lobby-display-weight);
  font-size: clamp(17px, 1.4vw, 22px);
  letter-spacing: 0.02em;
}

.lobby-game-select:hover {
  background: rgba(255, 255, 255, 0.1);
}

.lobby-game-select-name {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.lobby-game-select-chev {
  margin-left: auto;
  display: flex;
  color: var(--lobby-text-dim);
}

.lobby-game-select-chev svg {
  width: 18px;
  height: 18px;
}

/* v1.22 — the SAME chip as a live control, on any switchable lobby (Aside.tsx): the identical
   <button>, opening the game-switch modal below. Only the freeze state is its own rule — the
   static chip is never disabled.

   The launch freeze: same pixels, no affordance — the panel must not shift because Start was
   pressed. Dimmed like every other frozen control in this file. */
.lobby-game-select:disabled {
  opacity: 0.55;
  cursor: default;
}

.lobby-game-select:disabled:hover {
  background: rgba(255, 255, 255, 0.05);
}

.lobby-body {
  display: flex;
  flex-direction: column;
  gap: 16px;
  min-width: 0;
}

.lobby-title-sm {
  font-size: clamp(17px, 3vw, 21px);
  font-weight: 700;
  margin: 0;
  text-wrap: pretty;
}

/* Join mode's "You've been invited" line, first thing in the right column and that screen's
   headline, so it wears the house display face + CTA gradient (the head carries no phase
   copy at all - see buildHead - so the columns never move between phases). */
.lobby-invite {
  font-family: var(--lobby-display);
  font-weight: var(--lobby-display-weight);
  font-size: clamp(15px, 1.9vw, 19px);
  line-height: 1.45;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  text-align: center;
  background: linear-gradient(90deg, var(--lobby-cta-grad-a), var(--lobby-cta-grad-b));
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  text-wrap: balance;
}

/* Same deal for every status/error/hint line under a control. */
.lobby-status {
  font-size: 14px;
  line-height: 20px;
  min-height: 20px;
  margin: 0;
  color: var(--lobby-text-dim);
  text-align: center;
  text-wrap: pretty;
}

/* The lobby page's own slot (a direct child of the body, between the columns block and the
   invite row) sits inside two 16px body gaps, which with its 20px box put 52px of air above
   the invite link — half of that is enough (Jordan 2026-08-26). The negative margins tuck
   both neighbors 13px closer while the box keeps its 20px height, so a swap error appearing
   still moves nothing. The connecting screen's status line lives inside its own wrap, not as
   a body child, and keeps the plain spacing above. */
.lobby-body > .lobby-status {
  margin: -13px 0;
}

.lobby-status-error {
  color: var(--lobby-danger);
  font-weight: 600;
}

.lobby-field {
  display: flex;
  flex-direction: column;
  gap: 8px;
  text-align: left;
}

.lobby-label {
  font-size: 13px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--lobby-text-dim);
}

.lobby-input {
  width: 100%;
  height: var(--lobby-cta-h);
  padding: 0 22px;
  border-radius: 999px;
  border: 1px solid var(--lobby-panel-border);
  background: rgba(255, 255, 255, 0.06);
  color: var(--lobby-text);
  font-size: 17px;
  font-weight: 600;
  font-family: inherit;
}

.lobby-input::placeholder {
  color: var(--lobby-text-dim);
  font-weight: 500;
}

.lobby-input:focus {
  outline: 2px solid var(--lobby-accent);
  outline-offset: 1px;
  background: rgba(255, 255, 255, 0.1);
}

/* v1.13: the signed-in name, standing in the name field's slot (see buildAccountChip). It wears
   .lobby-input as well, so it is the same pill at the same height and the CTA below it does not
   move between a guest's screen and an account's - the only differences are the ones that say
   "this is not a control": no caret, no hover, and the dim label colour of a read-only value. */
.lobby-account-chip {
  display: flex;
  align-items: center;
  cursor: default;
  user-select: none;
  /* A div has no placeholder and no intrinsic ellipsis: a 16-char name cannot overflow the pill,
     but the mirror is written by another page and this is the cheap guarantee that a longer one
     still renders as one line. */
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}

/* v1.18 §2: the entry options — the ways in a browser with no identity is offered, stacked in the
   name field's slot (see EntryOptions). Flat and front-on like the rest of the panel.
   Amended 2026-08-29: two options with an "or" divider between them, convex mode only. */
.lobby-entry-options {
  display: flex;
  flex-direction: column;
  gap: 10px;
  width: 100%;
  padding: 30px;
  border: 1px solid var(--lobby-panel-border);
  border-radius: 20px;
  background: rgba(255, 255, 255, 0.04);
}

/* Inside the full-width card, the actionable column stays 370px and centred. */
.lobby-entry-options > .lobby-cta,
.lobby-entry-options > .lobby-field,
.lobby-entry-option,
.lobby-entry-or {
  width: 100%;
  max-width: 370px;
  margin-left: auto;
  margin-right: auto;
}

/* The question above the two ways in. A heading, not a label: sentence case, full text colour,
   centred over the 370px stack. */
.lobby-entry-title {
  font-size: 16px;
  font-weight: 700;
  color: var(--lobby-text);
  text-align: center;
  margin-bottom: 14px;
}

/* The divider between the two ways in. Presentational only - no testid, nothing to press: two
   hairlines in the panel's own border colour and the word between them, lowercase, at the dim
   label weight the rest of this panel uses for text that is not an action. */
.lobby-entry-or {
  display: flex;
  align-items: center;
  gap: 12px;
  margin-top: 10px;
  margin-bottom: 10px;
}

.lobby-entry-or-line {
  flex: 1;
  height: 1px;
  background: var(--lobby-panel-border);
}

.lobby-entry-or-word {
  font-size: 12px;
  font-weight: 600;
  letter-spacing: 0.08em;
  color: var(--lobby-text-dim);
}

/* The secondary member of that stack. Deliberately .lobby-cta's geometry and type - same height,
   same radius, same face - with the name field's translucent fill instead of the accent gradient,
   so the pills read as one control group and only one of them is the accent. */
.lobby-entry-option {
  width: 100%;
  min-height: var(--lobby-cta-h);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 12px 22px;
  border: 1px solid var(--lobby-panel-border);
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.06);
  color: var(--lobby-text);
  font-family: var(--lobby-cta-font);
  font-weight: 900;
  font-size: 12px;
  line-height: 1.5;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  text-align: center;
  cursor: pointer;
  transition: background 0.15s ease;
}

.lobby-entry-option:hover {
  background: rgba(255, 255, 255, 0.12);
}

/* The guest form's way back to the two options. A quiet centred link under the CTA. */
.lobby-entry-back {
  align-self: center;
  margin-top: 4px;
  padding: 0;
  border: none;
  background: none;
  color: var(--lobby-text-dim);
  font-size: 13px;
  font-weight: 600;
  cursor: pointer;
  text-decoration: underline;
  text-underline-offset: 3px;
}

.lobby-entry-back:hover {
  color: var(--lobby-text);
}

.lobby-btn {
  font-size: 17px;
  line-height: 22px;
  font-weight: 700;
  padding: 14px 24px;
  border-radius: 999px;
  border: none;
  cursor: pointer;
  font-family: inherit;
  transition: transform 0.15s ease, background 0.15s ease, opacity 0.15s ease;
}

.lobby-btn:disabled {
  opacity: 0.4;
  cursor: not-allowed;
}

.lobby-btn-secondary {
  background: rgba(255, 255, 255, 0.08);
  color: var(--lobby-text);
}

.lobby-btn-secondary:hover:not(:disabled) {
  background: rgba(255, 255, 255, 0.16);
}

/* ---- .lobby-cta: THE primary action, one component ----

   Every primary action in the lobby is this button - Create Game, Join Game, Start, Play again
   - and it is the homepage's `.play` pill (web/home/index.html): the pixel display face, a
   SOLID accent fill, near-black ink, fully rounded, hover = brightness only. Nothing here
   names a colour: --lobby-accent is the single per-game knob, so a game changes every primary
   button on every screen by setting one palette key. `.lobby-cta-big` is the same component at
   the size the lobby's final action (Start) wants. */
.lobby-cta {
  width: 100%;
  min-height: var(--lobby-cta-h);
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  padding: 12px 22px;
  border: none;
  border-radius: 999px;
  background: linear-gradient(90deg, var(--lobby-cta-grad-a), var(--lobby-cta-grad-b));
  color: #ffffff;
  font-family: var(--lobby-cta-font);
  font-weight: 900;
  font-size: 12px;
  line-height: 1.5;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  text-align: center;
  cursor: pointer;
  box-shadow: 0 0 24px color-mix(in srgb, var(--lobby-cta-grad-a) 50%, transparent);
  transition: transform 0.15s ease, filter 0.18s ease;
}

.lobby-cta:hover:not(:disabled) {
  transform: scale(1.02);
  filter: brightness(1.08);
}

/* Disabled is where the lobby writes WHY an action is blocked (the label itself), so it must
   stay readable - dimmed, never hidden, and with no hover affordance. */
.lobby-cta:disabled {
  opacity: 0.45;
  filter: none;
  cursor: not-allowed;
  box-shadow: none;
  animation: none;
}

.lobby-cta-big {
  min-height: var(--lobby-cta-big-h);
  font-size: clamp(15px, 1.5vw, 20px);
  letter-spacing: 0.06em;
}

/* The homepage's btnGlow pulse, generalised to the gradient pair. Enabled only on the big
   (final-action) pill, and the one animated thing in the lobby - so it is the one thing
   prefers-reduced-motion has to turn off. */
.lobby-cta-big:not(:disabled) {
  animation: lobby-btn-glow 2s ease-in-out infinite;
}

@keyframes lobby-btn-glow {
  0%,
  100% {
    box-shadow: 0 0 24px color-mix(in srgb, var(--lobby-cta-grad-a) 55%, transparent), 0 0 70px color-mix(in srgb, var(--lobby-cta-grad-b) 35%, transparent);
  }
  50% {
    box-shadow: 0 0 46px color-mix(in srgb, var(--lobby-cta-grad-a) 85%, transparent), 0 0 110px color-mix(in srgb, var(--lobby-cta-grad-b) 60%, transparent);
  }
}

@media (prefers-reduced-motion: reduce) {
  .lobby-cta-big:not(:disabled) {
    animation: none;
  }
}

.lobby-cta svg {
  width: 1.15em;
  height: 1.15em;
  flex: 0 0 auto;
}

/* Connecting phase: the body stretches to the grid row (the left column sets its height)
   so the spinner + status block can sit in the true middle of the right side instead of
   hanging at the top. */
.lobby-body-center {
  align-self: stretch;
  justify-content: center;
  align-items: center;
}

.lobby-center-block {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 14px;
  padding: 8px 0;
  text-align: center;
}

/* ---------- the pre-lobby hold (Jordan, 2026-08-30: "make that loading full width and centered,
   and a bit more attractive") ----------

   The connecting screen is the one pre-game state with nothing to read and nothing to press, so it
   stops pretending to be the two-column panel: the info column steps aside, the single column opens
   to the full shell width, and the spinner and its caption sit in the middle of the viewport.

   Same family as the launch curtain — an accent halo behind the spinner, a pulsing caption — but a
   step quieter, because this is a sub-second hold and not a launch: nothing here moves except
   opacity (and the spinner, which always did). Flat and front-on, like everything else on this
   panel: no tilt, no perspective, no card stack.

   Keyed off the state class the body wrapper already wears, with :has() — no component has to know
   this exists, which is what keeps it out of the markup contract. */
.lobby-panel:has(.lobby-body-center) .lobby-aside {
  display: none;
}

.lobby-panel.lobby-wide:has(.lobby-body-center) .lobby-columns {
  grid-template-columns: minmax(0, 1fr);
  justify-content: stretch;
}

/* The hold centres against the SCREEN, not against however tall its content happens to be. The
   overlay top-anchors the panel, so without a height the spinner sat a fixed ~300px from the top —
   "middle" on a short window, "top of the page" on a tall one (Jordan's ROAM panel, 2026-09-11).
   The chain: panel opens to the overlay's inner height (its padding subtracted), the columns row
   takes the slack, the body stretches into it, and the existing centring rules do the rest. */
.lobby-panel:has(.lobby-body-center) {
  min-height: calc(100vh - clamp(13px, 3vh, 27px) - 40px);
  min-height: calc(100dvh - clamp(13px, 3vh, 27px) - 40px);
}

.lobby-panel:has(.lobby-body-center) .lobby-columns {
  flex: 1;
  align-items: stretch;
}

.lobby-body-center .lobby-center-block {
  position: relative;
  gap: 22px;
  padding: 0;
}

/* The halo. One soft bloom of the game's own accent behind the spinner, breathing on opacity
   alone — a scaling halo under a spinning ring is two motions competing for the same 40px. */
.lobby-body-center .lobby-center-block::before {
  content: "";
  position: absolute;
  top: 27px; /* the spinner's centre: it is the first child and 54px tall */
  left: 50%;
  width: 340px;
  height: 340px;
  margin: -170px 0 0 -170px;
  border-radius: 50%;
  background: radial-gradient(circle, var(--lobby-accent) 0%, transparent 68%);
  opacity: 0.14;
  animation: lobby-hold-breathe 2.8s ease-in-out infinite;
  pointer-events: none;
}

/* Positioned, so they paint ABOVE the positioned ::before in DOM order. A negative z-index would
   do it too, until the overlay's own opacity fade turns it into a stacking context and the halo
   disappears behind the backdrop mid-transition. */
.lobby-body-center .lobby-spinner,
.lobby-body-center .lobby-status {
  position: relative;
}

.lobby-body-center .lobby-spinner {
  width: 54px;
  height: 54px;
  border-width: 3px;
  box-shadow: 0 0 26px -6px var(--lobby-accent);
}

.lobby-body-center .lobby-status {
  font-size: 12px;
  font-weight: 700;
  letter-spacing: 0.28em;
  text-transform: uppercase;
  animation: lobby-hold-pulse 1.8s ease-in-out infinite;
}

@keyframes lobby-hold-breathe {
  0%,
  100% {
    opacity: 0.09;
  }
  50% {
    opacity: 0.2;
  }
}

@keyframes lobby-hold-pulse {
  0%,
  100% {
    opacity: 0.5;
  }
  50% {
    opacity: 1;
  }
}

@media (prefers-reduced-motion: reduce) {
  .lobby-body-center .lobby-center-block::before,
  .lobby-body-center .lobby-status {
    animation: none;
  }
}

/* ---------- "ended": the results screen (contract addenda v1.5 -> v1.8) ----------

   Two shapes. Without an `endgame` the terminal state is the plain panel every other phase
   uses - one message line and the Play again button, nothing reserved for content that
   cannot arrive. With one, the viewport itself becomes the scoreboard: below.

   v1.8 layout. One fixed grid - half | centre column | half - with the two team colours
   painted underneath as an exact 50/50 split. Across the top runs a compact scoreboard
   strip: each half carries its team name and its score as one big numeral, and the centre
   column carries the paired team-total tiles level with the numerals. Below the strip comes
   a tall middle band, top-aligned across all three columns: the two player tables flanking
   the award stack. The chip sits at the bottom of the centre column; a gold banner is pinned
   across the winning half's top outer corner. Every slot is sized off vw/vh clamps, so the
   screen holds its proportions from a 1280x720 laptop to a 2560-wide desktop. The four
   custom properties on .lobby-results are the shared vertical rhythm: the three columns each
   stack name-h + score-h (+ strip-gap) before their band content, which is what lines the
   tables, the totals and the cards up across the divider. */
.lobby-ended-message {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: var(--lobby-ended-headline-h);
  /* A terminal message is one short line; clipping a pathological one is preferable to
     letting it push the button down and break the shell. */
  overflow: hidden;
  text-align: center;
  font-family: var(--lobby-display);
  font-weight: var(--lobby-display-weight);
  letter-spacing: 0.01em;
}

/* The overlay stops being a centred column of panel and becomes the screen. */
#lobby-overlay.lobby-results-mode {
  padding: 0;
  overflow: hidden;
  background: var(--lobby-bg);
  align-items: stretch;
  justify-content: stretch;
}

.lobby-results {
  position: relative;
  width: 100%;
  height: 100%;
  /* Scoped here on purpose: only this screen uses Inter, so no earlier phase can be caught
     mid-swap by a face it does not need. */
  font-family: var(--lobby-results-body);
  /* Clips the WINNER banner's off-screen overhang HERE, so the overlay's own scroll size
     never grows past the viewport (the screen is fixed; nothing on it may scroll). */
  overflow: hidden;
  display: grid;
  /* The centre column is the fixed part; the two halves take whatever is left, which keeps
     them symmetrical and the column exactly on the divider. */
  grid-template-columns: 1fr var(--lobby-results-centre-w) 1fr;
  align-items: stretch;
  /* Shared vertical rhythm (see the block comment above). */
  --lobby-results-name-h: clamp(48px, 9.25vh, 96px);
  --lobby-results-score-h: clamp(80px, 17.5vh, 200px);
  --lobby-results-strip-gap: clamp(20px, 17vh, 150px);
  --lobby-results-row-h: clamp(34px, 7.5vh, 64px);
  --lobby-results-card-h: clamp(44px, 8.5vh, 72px);
  /* A half's side padding: the outer edge gets the wider margin, the divider side the
     narrower one. Named because the name/score centring below has to account for them. */
  --lobby-results-pad-outer: clamp(24px, 3.1vw, 56px);
  --lobby-results-pad-inner: clamp(12px, 1.6vw, 28px);
}

.lobby-results-backdrop {
  position: absolute;
  inset: 0;
  display: flex;
}

/* S1 "Nebula Halves". Each half is still its own team colour - never a fade to black - but
   under four layers that turn a flat slab into deep space: a vertical black wash (40% -> 52%,
   which is what buys the white text its contrast), a horizontal darkening that runs INTO the
   centre line so the seam reads as the brightest thing on the screen, a soft bloom of the
   team colour lightened toward white in the upper third, and a faint white haze at the very
   bottom. The bloom and the haze are mirrored per side, hence the two rules below.

   The plain declaration comes first every time: a browser without color-mix() drops only the
   layered one and keeps a correctly-coloured, correctly-washed half. */
.lobby-results-half {
  flex: 1 1 50%;
  --lobby-half-bloom: #ffffff;
  --lobby-half-bloom: color-mix(in srgb, var(--lobby-results-side, #232838) 45%, #ffffff);
  background:
    linear-gradient(180deg, rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.52)),
    var(--lobby-results-side, #232838);
}

.lobby-results-half-left {
  background:
    radial-gradient(55% 60% at 32% 22%, color-mix(in srgb, var(--lobby-half-bloom) 31%, transparent), transparent 70%),
    radial-gradient(70% 30% at 35% 106%, rgba(255, 255, 255, 0.1), transparent 70%),
    linear-gradient(90deg, rgba(0, 0, 0, 0) 40%, rgba(0, 0, 0, 0.36) 100%),
    linear-gradient(180deg, rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.52)),
    var(--lobby-results-side, #232838);
}

.lobby-results-half-right {
  background:
    radial-gradient(55% 60% at 68% 22%, color-mix(in srgb, var(--lobby-half-bloom) 31%, transparent), transparent 70%),
    radial-gradient(70% 30% at 65% 106%, rgba(255, 255, 255, 0.1), transparent 70%),
    linear-gradient(270deg, rgba(0, 0, 0, 0) 40%, rgba(0, 0, 0, 0.36) 100%),
    linear-gradient(180deg, rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.52)),
    var(--lobby-results-side, #232838);
}

/* The star field. One layer across BOTH halves so no seam of its own lands on the centre
   line, and hand-placed rather than tiled - a repeating pattern reads as a texture, a fixed
   scatter reads as a sky. Static: nothing here animates. */
.lobby-results-stars {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background:
    radial-gradient(1.6px 1.6px at 42% 20%, rgba(255, 255, 255, 0.9), transparent 70%),
    radial-gradient(0.8px 0.8px at 7% 10%, rgba(255, 255, 255, 0.7), transparent 70%),
    radial-gradient(1px 1px at 75% 8%, rgba(255, 255, 255, 0.55), transparent 70%),
    radial-gradient(1.6px 1.6px at 12% 56%, rgba(255, 255, 255, 0.55), transparent 70%),
    radial-gradient(1.6px 1.6px at 31% 12%, rgba(255, 255, 255, 0.55), transparent 70%),
    radial-gradient(1px 1px at 73% 16%, rgba(255, 255, 255, 0.9), transparent 70%),
    radial-gradient(0.8px 0.8px at 81% 75%, rgba(255, 255, 255, 0.9), transparent 70%),
    radial-gradient(0.8px 0.8px at 75% 51%, rgba(255, 255, 255, 0.55), transparent 70%),
    radial-gradient(1px 1px at 6% 72%, rgba(255, 255, 255, 0.7), transparent 70%),
    radial-gradient(0.8px 0.8px at 54% 19%, rgba(255, 255, 255, 0.9), transparent 70%),
    radial-gradient(1px 1px at 40% 72%, rgba(255, 255, 255, 0.55), transparent 70%),
    radial-gradient(1px 1px at 75% 74%, rgba(255, 255, 255, 0.7), transparent 70%),
    radial-gradient(0.8px 0.8px at 13% 71%, rgba(255, 255, 255, 0.9), transparent 70%),
    radial-gradient(1px 1px at 8% 80%, rgba(255, 255, 255, 0.7), transparent 70%),
    radial-gradient(1.6px 1.6px at 88% 69%, rgba(255, 255, 255, 0.7), transparent 70%),
    radial-gradient(1.6px 1.6px at 60% 75%, rgba(255, 255, 255, 0.7), transparent 70%),
    radial-gradient(1px 1px at 39% 32%, rgba(255, 255, 255, 0.9), transparent 70%),
    radial-gradient(1.2px 1.2px at 32% 11%, rgba(255, 255, 255, 0.9), transparent 70%),
    radial-gradient(1.6px 1.6px at 64% 44%, rgba(255, 255, 255, 0.7), transparent 70%),
    radial-gradient(0.8px 0.8px at 78% 10%, rgba(255, 255, 255, 0.9), transparent 70%),
    radial-gradient(1.2px 1.2px at 54% 22%, rgba(255, 255, 255, 0.55), transparent 70%),
    radial-gradient(0.8px 0.8px at 63% 54%, rgba(255, 255, 255, 0.9), transparent 70%),
    radial-gradient(1.2px 1.2px at 10% 98%, rgba(255, 255, 255, 0.7), transparent 70%),
    radial-gradient(1.6px 1.6px at 89% 45%, rgba(255, 255, 255, 0.9), transparent 70%),
    radial-gradient(0.8px 0.8px at 59% 9%, rgba(255, 255, 255, 0.7), transparent 70%),
    radial-gradient(0.8px 0.8px at 61% 90%, rgba(255, 255, 255, 0.55), transparent 70%),
    radial-gradient(1.2px 1.2px at 94% 90%, rgba(255, 255, 255, 0.9), transparent 70%),
    radial-gradient(1.6px 1.6px at 74% 88%, rgba(255, 255, 255, 0.7), transparent 70%),
    radial-gradient(1.2px 1.2px at 92% 50%, rgba(255, 255, 255, 0.55), transparent 70%),
    radial-gradient(1px 1px at 60% 46%, rgba(255, 255, 255, 0.9), transparent 70%),
    radial-gradient(0.8px 0.8px at 15% 64%, rgba(255, 255, 255, 0.55), transparent 70%),
    radial-gradient(1px 1px at 99% 37%, rgba(255, 255, 255, 0.9), transparent 70%),
    radial-gradient(1.6px 1.6px at 32% 51%, rgba(255, 255, 255, 0.7), transparent 70%),
    radial-gradient(1.6px 1.6px at 11% 22%, rgba(255, 255, 255, 0.7), transparent 70%),
    radial-gradient(1px 1px at 71% 36%, rgba(255, 255, 255, 0.7), transparent 70%),
    radial-gradient(1.6px 1.6px at 71% 36%, rgba(255, 255, 255, 0.7), transparent 70%),
    radial-gradient(1px 1px at 88% 49%, rgba(255, 255, 255, 0.55), transparent 70%),
    radial-gradient(1px 1px at 11% 23%, rgba(255, 255, 255, 0.55), transparent 70%),
    radial-gradient(0.8px 0.8px at 85% 30%, rgba(255, 255, 255, 0.7), transparent 70%),
    radial-gradient(1.2px 1.2px at 76% 24%, rgba(255, 255, 255, 0.7), transparent 70%),
    radial-gradient(1.6px 1.6px at 1% 19%, rgba(255, 255, 255, 0.9), transparent 70%),
    radial-gradient(1.2px 1.2px at 48% 79%, rgba(255, 255, 255, 0.55), transparent 70%),
    radial-gradient(0.8px 0.8px at 89% 66%, rgba(255, 255, 255, 0.7), transparent 70%),
    radial-gradient(1.6px 1.6px at 88% 72%, rgba(255, 255, 255, 0.7), transparent 70%),
    radial-gradient(0.8px 0.8px at 52% 51%, rgba(255, 255, 255, 0.7), transparent 70%),
    radial-gradient(0.8px 0.8px at 82% 52%, rgba(255, 255, 255, 0.55), transparent 70%);
}

/* ---- the gold WINNER frame ---- */

/* Four gold edges around the winning half. Its own layer rather than a border on the half,
   because a border there would eat into the exact 50/50 split; its inner edge sits ON the
   centre line, which is why the screen carries no divider rule of its own. Above the halves,
   below the centre column - the totals rows and the award cards straddle the seam and have
   to sit on top of it. */
.lobby-results-frame {
  position: absolute;
  top: 0;
  bottom: 0;
  width: 50%;
  z-index: 2;
  pointer-events: none;
  /* 4px at 1280, up to 6 on a wide desktop. A border-width is floored to whole device pixels,
     so the low end is stated as 4px rather than a vw that rounds down to 3. */
  border: clamp(4px, 0.31vw, 6px) solid #f0bd3f;
  box-shadow: 0 0 26px rgba(245, 197, 66, 0.5);
}

.lobby-results-frame-left {
  left: 0;
}

.lobby-results-frame-right {
  right: 0;
}

/* ---- the WINNER banner ---- */

/* Gold ribbon pinned across the winning half's top OUTER corner, rotated 40deg and hanging
   past the edge on both ends so only the middle of the band is on screen. Sized off one
   width so the offsets keep their proportion at every viewport. The only asymmetry on the
   screen and the only colour that belongs to neither side. */
.lobby-results-winner {
  --lobby-banner-w: clamp(240px, 28vw, 420px);
  position: absolute;
  z-index: 3;
  top: calc(var(--lobby-banner-w) * 0.155);
  width: var(--lobby-banner-w);
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.6em;
  padding: clamp(7px, 1.2vh, 12px) 0;
  background: linear-gradient(180deg, #ffeeb6, #efbc46);
  color: #4a3406;
  font-weight: 900;
  font-size: clamp(10px, 1vw, 16px);
  letter-spacing: 0.3em;
  white-space: nowrap;
  box-shadow: 0 0 34px rgba(245, 197, 66, 0.55), 0 10px 28px rgba(0, 0, 0, 0.45);
}

.lobby-results-winner svg {
  width: 1.25em;
  height: 1.25em;
  flex: 0 0 auto;
}

.lobby-results-winner-right {
  right: calc(var(--lobby-banner-w) * -0.255);
  transform: rotate(40deg);
}

.lobby-results-winner-left {
  left: calc(var(--lobby-banner-w) * -0.255);
  transform: rotate(-40deg);
}

/* ---- a half: team name, score numeral, then the table ---- */

.lobby-results-team {
  position: relative;
  z-index: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
  align-items: stretch;
  justify-content: flex-start;
  padding: 0 var(--lobby-results-pad-inner) clamp(16px, 3vh, 40px) var(--lobby-results-pad-outer);
}

.lobby-results-team-right {
  padding: 0 var(--lobby-results-pad-outer) clamp(16px, 3vh, 40px) var(--lobby-results-pad-inner);
}

/* The name and the numeral are centred on the HALF, not on the team column (which stops at
   the centre column's edge): a margin on the divider-facing side, sized so the slot's centre
   lands on the half's centre. A margin rather than a transform, because the entrance
   animates transform on these two elements. */
.lobby-results-team-left .lobby-results-teamname,
.lobby-results-team-left .lobby-results-score {
  margin-left: calc(var(--lobby-results-centre-w) / 2 - var(--lobby-results-pad-outer) + var(--lobby-results-pad-inner));
}

.lobby-results-team-right .lobby-results-teamname,
.lobby-results-team-right .lobby-results-score {
  margin-right: calc(var(--lobby-results-centre-w) / 2 - var(--lobby-results-pad-outer) + var(--lobby-results-pad-inner));
}

/* Display face, sat at the bottom of its fixed-height slot so the numeral below lands at the
   same y on both halves whatever the name. One ellipsised line.

   The fill is a gradient of the team's OWN colour - a near-white tint at the cap line falling
   to the colour itself at the baseline - lit by a drop-shadow in the same colour. Both stops
   are mixed from --lobby-results-side, so the rule names no game. The flat white `color`
   above the gradient is the fallback: without color-mix() the gradient declaration is dropped
   whole and the name stays plain white, never invisible. */
.lobby-results-teamname {
  height: var(--lobby-results-name-h);
  padding-top: calc(var(--lobby-results-name-h) - 1em - clamp(8px, 1.5vh, 14px));
  font-family: var(--lobby-results-display);
  font-weight: 900;
  font-style: italic;
  font-size: clamp(14px, 2.35vw, 34px);
  letter-spacing: 0.02em;
  line-height: 1;
  text-align: center;
  text-transform: uppercase;
  color: #ffffff;
  text-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

@supports ((color: color-mix(in srgb, red 50%, white)) and ((-webkit-background-clip: text) or (background-clip: text))) {
  .lobby-results-teamname {
    background: linear-gradient(
      180deg,
      color-mix(in srgb, var(--lobby-results-side, #232838) 12%, #ffffff) 0%,
      color-mix(in srgb, var(--lobby-results-side, #232838) 70%, #ffffff) 100%
    );
    -webkit-background-clip: text;
    background-clip: text;
    color: transparent;
    text-shadow: none;
    filter:
      drop-shadow(0 0 14px color-mix(in srgb, var(--lobby-results-side, #232838) 50%, transparent))
      drop-shadow(0 6px 18px rgba(0, 0, 0, 0.45));
  }
}

/* The side's score: one big numeral in the heavy italic display face, dead centre on its
   half, sized off the slot it lives in so it can never outgrow it. White falling to a cool
   violet-white; the winner's the same shape in gold, haloed. The flat `color` before each
   gradient is the no-background-clip fallback. */
.lobby-results-score {
  height: var(--lobby-results-score-h);
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: var(--lobby-results-display);
  font-style: italic;
  font-size: min(var(--lobby-results-score-h), 11vw);
  font-weight: 900;
  letter-spacing: -0.02em;
  line-height: 1;
  font-variant-numeric: tabular-nums;
  color: rgba(255, 255, 255, 0.94);
  text-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
  white-space: nowrap;
  overflow: hidden;
}

.lobby-results-score-win {
  color: #ffe9a8;
  text-shadow: 0 12px 40px rgba(0, 0, 0, 0.45), 0 0 44px rgba(255, 213, 108, 0.4);
}

/* Guarded: an engine without background-clip:text keeps the flat fills above rather than a
   transparent numeral over nothing. */
@supports ((-webkit-background-clip: text) or (background-clip: text)) {
  .lobby-results-score {
    background: linear-gradient(180deg, #ffffff 0%, #cfd6ff 100%);
    -webkit-background-clip: text;
    background-clip: text;
    color: transparent;
    text-shadow: none;
    filter: drop-shadow(0 14px 36px rgba(0, 0, 0, 0.6));
  }

  /* background-IMAGE, not the shorthand: `background` would reset background-clip back to
     border-box and paint the numeral's whole box gold. */
  .lobby-results-score-win {
    background-image: linear-gradient(180deg, #fff6cf 0%, #f3c349 100%);
    color: transparent;
    text-shadow: none;
    filter: drop-shadow(0 0 34px rgba(245, 197, 66, 0.45)) drop-shadow(0 14px 36px rgba(0, 0, 0, 0.6));
  }
}

.lobby-results-table {
  margin-top: var(--lobby-results-strip-gap);
  display: flex;
  flex-direction: column;
  gap: clamp(4px, 0.75vh, 8px);
}

/* One row = one grid, so the two halves' columns line up with each other across the screen
   even though they are two independent tables. */
.lobby-results-row {
  display: grid;
  grid-template-columns: clamp(18px, 2vw, 34px) minmax(0, 1fr) clamp(44px, 4.6vw, 80px) clamp(44px, 4.6vw, 80px) clamp(44px, 4.6vw, 80px);
  align-items: center;
  gap: clamp(6px, 0.75vw, 14px);
  height: var(--lobby-results-row-h);
  padding: 0 clamp(10px, 1.1vw, 18px);
  border-radius: 12px;
  /* Barely-there glass. The half beneath is now a dark nebula, so a row needs only the
     faintest lift to separate - the v1.8 tint would band the whole column. */
  border: 1px solid rgba(255, 255, 255, 0.08);
  background: rgba(255, 255, 255, 0.05);
  font-size: clamp(12px, 1.2vw, 18px);
  font-weight: 700;
  color: #ffffff;
}

/* v1.7: a sender with four stat columns (endgame.columns) gets slightly narrower numeric
   tracks so the name keeps its share of the row. Widths only - the row height is fixed. */
.lobby-results-table[data-cols="4"] .lobby-results-row {
  grid-template-columns: clamp(18px, 2vw, 34px) minmax(0, 1fr) clamp(36px, 3.6vw, 62px) clamp(36px, 3.6vw, 62px) clamp(36px, 3.6vw, 62px) clamp(40px, 4vw, 70px);
}

/* The header is the same grid with no pill behind it, so the labels sit exactly over their
   columns. Labels render in caps whatever the sender wrote (the DOM text is untouched, so a
   legacy "Passes" still reads PASSES and a payload label keeps its own spelling underneath). */
.lobby-results-head {
  height: clamp(16px, 2.75vh, 26px);
  background: none;
  border-color: transparent;
  color: rgba(255, 255, 255, 0.5);
  font-size: clamp(8px, 0.72vw, 12px);
  font-weight: 800;
  letter-spacing: 0.14em;
  text-transform: uppercase;
}

/* You. A light rule and a lit box, not a different colour: the row still belongs to its
   team - it is just the one the reader is looking for. */
.lobby-results-row-self {
  border-color: rgba(255, 255, 255, 0.9);
  background: rgba(255, 255, 255, 0.16);
  box-shadow: 0 0 18px rgba(255, 255, 255, 0.25), inset 0 0 18px rgba(255, 255, 255, 0.1);
}

/* The rank sits in its own chip. Fixed box, centred glyph - it is one character and it never
   changes the row's height. */
.lobby-results-row:not(.lobby-results-head) .lobby-results-rank {
  display: flex;
  align-items: center;
  justify-content: center;
  height: clamp(20px, 3.5vh, 30px);
  border-radius: 8px;
  background: rgba(255, 255, 255, 0.08);
  color: rgba(255, 255, 255, 0.8);
  font-size: clamp(10px, 0.95vw, 14px);
}

.lobby-results-cell {
  min-width: 0;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}

.lobby-results-rank {
  color: rgba(255, 255, 255, 0.7);
}

/* Characters phase 2: the portrait beside a name on the results screen. Inline, before the text,
   so the cell keeps its ellipsis and the row keeps its fixed height - the picture is capped well
   under --lobby-results-row-h at every viewport, and a row for a player without one is the row
   this screen has always drawn (no reserved gutter, no shifted names). The tinted background is
   what a transparent portrait sits on, and what a still-loading one shows. */
.lobby-results-portrait {
  width: clamp(18px, 1.8vw, 28px);
  height: clamp(18px, 1.8vw, 28px);
  margin-right: clamp(5px, 0.55vw, 10px);
  border-radius: 50%;
  object-fit: cover;
  vertical-align: middle;
  background: rgba(255, 255, 255, 0.14);
}

.lobby-results-num,
.lobby-results-total {
  text-align: right;
  font-variant-numeric: tabular-nums;
}

.lobby-results-num {
  color: rgba(255, 255, 255, 0.9);
  font-weight: 700;
}

.lobby-results-total {
  font-size: clamp(14px, 1.45vw, 22px);
  font-weight: 900;
}

/* The header's cells are labels, not values: they inherit the header's small type, or
   "SCORE" ellipsises inside a column sized for one digit. */
.lobby-results-head .lobby-results-cell {
  font: inherit;
  letter-spacing: inherit;
}

/* ---- the centre column ---- */

.lobby-results-centre {
  position: relative;
  z-index: 2;
  min-width: 0;
  display: flex;
  flex-direction: column;
  align-items: stretch;
  /* Top padding = the halves' name slot, so the totals box below starts level with the
     numerals. */
  padding: var(--lobby-results-name-h) 0 clamp(14px, 2.4vh, 30px);
}

/* Exactly the numerals' height, with the total rows centred inside it - which is what puts
   the tiles level with the digits. Present (and empty) when the sender has no totals, so the
   award stack below still lines up with the tables. */
.lobby-results-totalsbox {
  height: var(--lobby-results-score-h);
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: clamp(4px, 1vh, 8px);
}

/* One row = ONE opaque block spanning the divider, holding the two sides' figures side by
   side with no seam and no rule between them: the pair is a single comparison, not two tiles
   that happen to touch. The block carries the two teams instead in its EDGE - a 1.5px border
   graded left colour -> right colour, drawn with the padding-box/border-box trick - and in
   its glow, which spills the left colour off the left flank and the right colour off the
   right. Opaque on purpose: it sits over the centre line, where a translucent fill would show
   both halves through it.

   The first `background`/`border-color` pair is the fallback: without color-mix() the block
   keeps its dark fill and a plain light edge. */
.lobby-results-totals {
  display: grid;
  grid-template-columns: 1fr 1fr;
  width: 100%;
  border-radius: 10px;
  border: 1.5px solid rgba(255, 255, 255, 0.4);
  background: #0d0b22;
  box-shadow: 0 10px 26px rgba(0, 0, 0, 0.35);
}

@supports (color: color-mix(in srgb, red 50%, white)) {
  .lobby-results-totals {
    border-color: transparent;
    background:
      linear-gradient(#0d0b22, #0d0b22) padding-box,
      linear-gradient(
          90deg,
          color-mix(in srgb, color-mix(in srgb, var(--lobby-results-side-left, #232838) 80%, #ffffff) 90%, transparent),
          color-mix(in srgb, color-mix(in srgb, var(--lobby-results-side-right, #232838) 80%, #ffffff) 90%, transparent)
        )
        border-box;
    box-shadow:
      -10px 0 24px color-mix(in srgb, var(--lobby-results-side-left, #232838) 32%, transparent),
      10px 0 24px color-mix(in srgb, var(--lobby-results-side-right, #232838) 36%, transparent),
      0 10px 26px rgba(0, 0, 0, 0.35);
  }
}

.lobby-results-total-tile {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 4px;
  height: clamp(36px, 7.25vh, 64px);
  color: #ffffff;
  min-width: 0;
}

.lobby-results-total-label {
  font-size: clamp(8px, 0.72vw, 12px);
  font-weight: 800;
  letter-spacing: 0.16em;
  opacity: 0.75;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  max-width: 100%;
}

.lobby-results-total-value {
  font-size: clamp(16px, 2.05vw, 32px);
  font-weight: 900;
  font-variant-numeric: tabular-nums;
  line-height: 1;
}

/* The award stack, level with the tables. No panel of its own: the cards float on the divider
   against both halves. */
.lobby-results-awards {
  margin-top: var(--lobby-results-strip-gap);
  display: flex;
  flex-direction: column;
  gap: clamp(4px, 0.9vh, 8px);
}

/* One award: dark glass, EDGED and LIT in the winner's team colour rather than filled with
   it. Opaque, like the totals block and for the same reason - the card floats on the centre
   line with a different half behind each end of it. The plain border-colour before the
   color-mix one is the fallback; the fill never depended on color-mix at all. */
.lobby-super-card {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  align-items: center;
  gap: clamp(6px, 0.85vw, 12px);
  height: var(--lobby-results-card-h);
  padding: 0 clamp(8px, 1.1vw, 16px);
  border-radius: 14px;
  border: 1.5px solid rgba(255, 255, 255, 0.5);
  background: #0d0b22;
  box-shadow: 0 10px 26px rgba(0, 0, 0, 0.45);
  overflow: hidden;
}

@supports (color: color-mix(in srgb, red 50%, white)) {
  .lobby-super-card {
    border-color: color-mix(in srgb, color-mix(in srgb, var(--lobby-super-accent, #ffffff) 80%, #ffffff) 85%, transparent);
    box-shadow:
      0 0 22px color-mix(in srgb, var(--lobby-super-accent, #ffffff) 45%, transparent),
      0 10px 26px rgba(0, 0, 0, 0.45),
      inset 0 0 24px color-mix(in srgb, var(--lobby-super-accent, #ffffff) 14%, transparent);
  }
}

.lobby-super-emoji {
  font-size: clamp(16px, 1.9vw, 28px);
  line-height: 1;
}

/* The award's own name, in a light tint of its winner's colour - the one place on the card
   the team reads as text rather than as an edge. White is the fallback. */

.lobby-super-text {
  min-width: 0;
}

/* Every line is one line: ellipsised rows are what keeps the card's height constant
   regardless of how long a display name turns out to be. */
.lobby-super-title,
.lobby-super-player,
.lobby-super-metric {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.lobby-super-title {
  font-size: clamp(8px, 0.72vw, 12px);
  font-weight: 900;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  line-height: 1.2;
  color: #ffffff;
  color: color-mix(in srgb, var(--lobby-super-accent, #ffffff) 45%, #ffffff);
}

/* The same size and weight as a table row's player name. */
.lobby-super-player {
  font-size: clamp(12px, 1.2vw, 18px);
  font-weight: 700;
  line-height: 1.35;
  color: #ffffff;
}

/* The count and its unit, centred as one block on the right. */
.lobby-super-stat {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 2px;
  min-width: clamp(34px, 4vw, 64px);
  max-width: clamp(56px, 6vw, 96px);
}

.lobby-super-value {
  font-size: clamp(14px, 1.6vw, 26px);
  font-weight: 900;
  font-variant-numeric: tabular-nums;
  line-height: 1;
  color: #ffffff;
  white-space: nowrap;
}

.lobby-super-metric {
  font-size: clamp(7px, 0.64vw, 10px);
  font-weight: 800;
  letter-spacing: 0.1em;
  opacity: 0.75;
  max-width: 100%;
}

/* Unclaimed award: same card, same height, unlit - nothing to celebrate. Declared AFTER the
   @supports block above, so it overrides the team edge and glow at equal specificity. */
.lobby-super-card-empty {
  border-color: rgba(255, 255, 255, 0.12);
  background: #0d0b22;
  box-shadow: 0 10px 26px rgba(0, 0, 0, 0.45);
}

.lobby-super-card-empty .lobby-super-emoji {
  opacity: 0.35;
}

.lobby-super-card-empty .lobby-super-title,
.lobby-super-card-empty .lobby-super-player {
  color: var(--lobby-text-dim);
}

/* Pushes the chip to the bottom of the column. */
.lobby-results-spacer {
  flex: 1 1 auto;
  min-height: clamp(10px, 2.5vh, 30px);
}

/* The one control. It says where it is going and when; clicking it goes now. */
.lobby-results-return {
  align-self: center;
  margin-top: clamp(6px, 1vh, 12px);
  padding: clamp(9px, 1.6vh, 16px) clamp(18px, 2.35vw, 38px);
  border-radius: 999px;
  border: 1px solid rgba(255, 255, 255, 0.4);
  background: rgba(8, 6, 24, 0.82);
  color: #ffffff;
  font-family: inherit;
  font-size: clamp(10px, 1.02vw, 16px);
  font-weight: 800;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  /* The countdown digit changes every second; tabular figures keep the pill's box still
     (matchtest's rule-4 CTA geometry check flakes without this - seen twice). */
  font-variant-numeric: tabular-nums;
  box-shadow:
    0 0 22px rgba(255, 255, 255, 0.16),
    0 10px 30px rgba(0, 0, 0, 0.45),
    inset 0 1px 0 rgba(255, 255, 255, 0.25);
  /* The countdown digit changes every second; tabular figures keep the chip's box still. */
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
  cursor: pointer;
  transition: background 120ms ease, border-color 120ms ease;
}

.lobby-results-return:hover:not(:disabled) {
  background: rgba(26, 22, 54, 0.9);
  border-color: rgba(255, 255, 255, 0.62);
}

/* v1.6: a peer that may not start the rematch keeps the chip's slot, dimmed and inert, so the
   screen's composition is identical on every peer. */
.lobby-results-return:disabled {
  opacity: 0.5;
  cursor: default;
}

/* One continuous ramp, in reading order. Transform + opacity only - every element already
   occupies its final box before the animation starts, so the entrance cannot shift anything.
   Delays are set per element in lobby.js. */
@keyframes lobby-results-in {
  from {
    opacity: 0;
    transform: translateY(10px);
  }
  to {
    opacity: 1;
    transform: none;
  }
}

.lobby-results-enter .lobby-results-teamname,
.lobby-results-enter .lobby-results-score,
.lobby-results-enter .lobby-results-totals,
.lobby-results-enter .lobby-results-row,
.lobby-results-enter .lobby-super-card,
/* v1.23: the free-for-all headline, which stands where the two team names do. */
.lobby-results-enter .lobby-ffa-head,
.lobby-results-enter .lobby-results-return {
  opacity: 0;
  animation: lobby-results-in 380ms cubic-bezier(0.22, 0.8, 0.3, 1) forwards;
}

/* T4: the backdrop - the two halves and the star field - used to snap on as a wall of colour
   under content that was still arriving. It gets its own, slightly longer fade underneath the
   ramp above, on the SAME latch (lobby.js plays the entrance once per results screen, so a
   repeat "ended" push cannot replay it). Opacity only: the layer is inset: 0 behind everything,
   and `both` covers the frame before it starts. */
@keyframes lobby-results-backdrop-in {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

.lobby-results-enter .lobby-results-backdrop {
  animation: lobby-results-backdrop-in 320ms ease-out both;
}

/* The gold winner frame is a layer ON the backdrop (absolute, z-index: 2), so it has to arrive
   with it: left out of the entrance it hangs as a bright gold rectangle over the flat page
   while the team colours are still washing in underneath it. Same fade one beat later, so the
   halves land first and the frame reads as being drawn around a half that is already there.
   Opacity only, and `both` holds it at 0 through the delay. */
.lobby-results-enter .lobby-results-frame {
  animation: lobby-results-backdrop-in 320ms ease-out 120ms both;
}

/* The banner is placed BY a rotation, so the generic keyframe's `transform: none` would snap
   it flat when the entrance lands. It gets the same motion with its rotation folded in - one
   keyframe per side, because the two are rotated opposite ways. */
@keyframes lobby-results-in-banner-right {
  from {
    opacity: 0;
    transform: translateY(10px) rotate(40deg);
  }
  to {
    opacity: 1;
    transform: rotate(40deg);
  }
}

@keyframes lobby-results-in-banner-left {
  from {
    opacity: 0;
    transform: translateY(10px) rotate(-40deg);
  }
  to {
    opacity: 1;
    transform: rotate(-40deg);
  }
}

.lobby-results-enter .lobby-results-winner-right {
  opacity: 0;
  animation: lobby-results-in-banner-right 380ms cubic-bezier(0.22, 0.8, 0.3, 1) forwards;
}

.lobby-results-enter .lobby-results-winner-left {
  opacity: 0;
  animation: lobby-results-in-banner-left 380ms cubic-bezier(0.22, 0.8, 0.3, 1) forwards;
}

.lobby-spinner {
  width: 40px;
  height: 40px;
  border-radius: 50%;
  border: 3px solid rgba(255, 255, 255, 0.15);
  border-top-color: var(--lobby-accent);
  animation: lobby-spin 0.8s linear infinite;
  flex-shrink: 0;
}

.lobby-spinner-sm {
  width: 20px;
  height: 20px;
  border-width: 2px;
}

@keyframes lobby-spin {
  to {
    transform: rotate(360deg);
  }
}

.lobby-teams {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 12px;
}

/* A side's panel. Flat, front-on, and wearing its OWN colour: a low-alpha tint of it as the
   fill, a bright 2px edge of it, and a soft bloom of it underneath. --lobby-team is set inline
   per column from LOBBY_CONFIG.teamColors (lobby.js buildTeamColumn), so the stylesheet still
   names no game and no colour. The plain declarations are the fallback: without color-mix()
   a column keeps the old neutral panel skin rather than losing its border. */
.lobby-team-col {
  background: var(--lobby-panel);
  border: 2px solid var(--lobby-panel-border);
  border-radius: var(--lobby-team-radius);
  padding: 12px 11px 11px;
  display: flex;
  flex-direction: column;
  gap: 8px;
  min-width: 0;
  transition: background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
}

@supports (color: color-mix(in srgb, red 50%, white)) {
  .lobby-team-col {
    background: color-mix(in srgb, var(--lobby-team, #232838) 14%, transparent);
    border-color: color-mix(in srgb, var(--lobby-team, #232838) 72%, #ffffff);
    box-shadow: 0 0 34px color-mix(in srgb, var(--lobby-team, #232838) 30%, transparent),
      0 24px 60px rgba(0, 0, 0, 0.4);
  }
}

/* Column header: the side's name in the display face, its count on the right. The name lives
   in its own element and holds NOTHING but the name - the roster gates read it as text. */
.lobby-team-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 8px;
  min-width: 0;
  padding: 0 2px 2px;
}

.lobby-team-count {
  flex: 0 0 auto;
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.16em;
  color: var(--lobby-text-dim);
  font-variant-numeric: tabular-nums;
}

/* Both drag states snap on instantly (transition: none) - a drop target answering 150ms late
   reads as lag under the pointer; the fade-back on leave keeps the base transition. */
.lobby-team-col.lobby-drop-target {
  transition: none;
  border-color: #ffffff;
  background: rgba(255, 255, 255, 0.12);
}

@supports (color: color-mix(in srgb, red 50%, white)) {
  .lobby-team-col.lobby-drop-target {
    border-color: color-mix(in srgb, var(--lobby-team, #ffffff) 55%, #ffffff);
    background: color-mix(in srgb, var(--lobby-team, #232838) 32%, transparent);
    box-shadow: 0 0 48px color-mix(in srgb, var(--lobby-team, #232838) 60%, transparent),
      0 24px 60px rgba(0, 0, 0, 0.4);
  }
}

/* Dragging over a full side: the drop is still allowed to happen, it just tells you
   in advance how it will end. The refusal itself is .lobby-col-error below. */
.lobby-team-col.lobby-drop-blocked {
  transition: none;
  border-color: rgba(255, 107, 107, 0.6);
  background: rgba(255, 107, 107, 0.08);
}

.lobby-team-col.lobby-col-error {
  border-color: var(--lobby-danger);
  background: rgba(255, 107, 107, 0.14);
  animation: lobby-shake 0.4s ease;
}

@keyframes lobby-shake {
  0%,
  100% {
    transform: translateX(0);
  }
  20% {
    transform: translateX(-6px);
  }
  40% {
    transform: translateX(5px);
  }
  60% {
    transform: translateX(-3px);
  }
  80% {
    transform: translateX(2px);
  }
}

/* Unbounded 900 runs WIDE. The column is half of half the shell, so the name is clamped and
   ellipsised rather than allowed to push its count off the edge. */
.lobby-team-label {
  min-width: 0;
  font-family: var(--lobby-display);
  font-size: clamp(11px, 1.1vw, 15px);
  font-weight: var(--lobby-display-weight);
  line-height: 1.3;
  letter-spacing: 0.02em;
  color: #ffffff;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

@supports (color: color-mix(in srgb, red 50%, white)) {
  .lobby-team-label {
    text-shadow: 0 0 18px color-mix(in srgb, var(--lobby-team, #232838) 70%, transparent);
  }
}

/* ---- v1.12 §5b: inline team names (convex mode only) ----
   The label element is unchanged for everyone else; for a viewer allowed to rename the side it
   becomes a real <button> wearing the same type in the same box, plus the pencil below. No other
   chrome, so the header's height is identical between the peers that may edit and the peers
   that may not.

   At rest the button is PURE TEXT: appearance:none plus the three resets below leave it with no
   box, no fill and no chrome of its own, so a name you may rename looks exactly like one you may
   not. NO font-family here - it used to say `inherit`, which (same specificity, declared later)
   beat .lobby-team-label's var(--lobby-display) and rendered every editable name in the panel's
   body sans while the plain <div> next to it kept Unbounded. Letting the label rule win is the
   whole pin: div, button and input now share one face, size, weight and line box. */
.lobby-team-label-edit {
  appearance: none;
  padding: 0;
  border: none;
  background: none;
  text-align: left;
  cursor: pointer;
  transition: color 0.12s ease;
}

/* v1.13: the affordance is always visible, not hover-only - the button only exists for a viewer
   who may actually rename this side, so the pencil IS the permission. It rides in the label's own
   line box (an inline-block well inside line-height 1.3, no margins that grow the box), so the
   header keeps its height and nothing below it moves. A masked SVG rather than a glyph: pseudo
   content is empty, so innerText assertions on the name still read the name and nothing else. */
.lobby-team-label-edit::after {
  content: "";
  display: inline-block;
  width: 0.8em;
  height: 0.8em;
  margin-left: 0.38em;
  vertical-align: -0.05em;
  background: currentColor;
  opacity: 0.22;
  transition: opacity 0.12s ease;
  -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z'/%3E%3C/svg%3E")
    no-repeat center / contain;
  mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z'/%3E%3C/svg%3E")
    no-repeat center / contain;
}

/* Hover moves the PENCIL, not the name: 0.22 -> 0.9 is a jump you cannot miss, and the name it
   sits beside stays at full strength (the old rule dimmed the whole button to 0.82, which faded
   the very word you were reaching for). */
.lobby-team-label-edit:hover::after {
  opacity: 0.9;
}

/* White, not the accent - the ring is a focus cue, not a brand mark. */
.lobby-team-label-edit:focus-visible {
  outline: 2px solid rgba(255, 255, 255, 0.85);
  outline-offset: 3px;
  border-radius: 4px;
}

/* The open editor, in the label's EXACT box: same face, same size, same line box, so opening it
   shifts nothing in the header or the column under it. The frame is an inset shadow rather than a
   border for exactly that reason - a 1px border would make the field two pixels taller than the
   label it replaced, and the whole column would step down as it opened. */
.lobby-team-name-input {
  /* flex-basis 0, NOT auto: with `auto` the basis is the input's own UA intrinsic width - the
     `size` attribute, ~20 characters of whatever face it is wearing - so the field only ever
     reached the header's edge by shrinking back into it, and a header wider than those 20
     characters would have left it hugging its text. Basis 0 makes it GROW into everything left
     over after the seat count instead, whatever the face or the column width. */
  flex: 1 1 0;
  min-width: 0;
  height: auto;
  margin: 0;
  padding: 0 4px;
  border: none;
  border-radius: 5px;
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.85);
  background: rgba(255, 255, 255, 0.08);
  text-shadow: none;
  text-overflow: clip;
}

.lobby-team-name-input:focus {
  outline: none;
}

/* A query container so a card can react to the width of ITS OWN column rather than to the
   viewport: the column is half of half the shell, so the same 1000px-wide window that is
   roomy for the page is cramped for a card, and the same card is roomy on a phone in the
   stacked layout. `.lobby-card-tag` below is what that buys. */
.lobby-team-slots {
  display: flex;
  flex-direction: column;
  gap: 6px;
  container-type: inline-size;
}

/* One roster slot: avatar, name, right-aligned tag, then the row's action zone (the drag grip
   on your own card, the ✕ on a host's bot card). Fixed height, so a column's height is a
   function of maxPerTeam alone and a name arriving cannot move the invite row below it. */
.lobby-card {
  display: flex;
  align-items: center;
  gap: 7px;
  height: var(--lobby-card-h);
  padding: 0 8px;
  border-radius: var(--lobby-card-radius);
  border: 1.5px solid rgba(255, 255, 255, 0.1);
  background: rgba(255, 255, 255, 0.05);
  font-size: 13.5px;
  font-weight: 600;
  text-align: left;
  overflow-wrap: anywhere;
}

@supports (color: color-mix(in srgb, red 50%, white)) {
  .lobby-card {
    border-color: color-mix(in srgb, var(--lobby-team, #232838) 45%, transparent);
  }
}

/* Last in the card, not first (2026-08-24): the LEFT edge of every row is the avatar, and the
   right edge is where everything pressable lives. Its own auto margin pins it to that edge on
   a card whose tag the container query below has dropped. */
.lobby-card-grip {
  width: 10px;
  height: 16px;
  flex: 0 0 auto;
  margin-left: auto;
  opacity: 0.45;
  fill: currentColor;
}

.lobby-card-avatar {
  width: 24px;
  height: 24px;
  flex: 0 0 auto;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 12px;
  font-weight: 800;
  line-height: 1;
  color: #ffffff;
  background: var(--lobby-team, rgba(255, 255, 255, 0.14));
}

/* Characters phase 2: the player's saved portrait, laid OVER the initial inside the same circle
   rather than replacing it. Absolutely positioned (the circle is already a containing block
   further down, for the crown), so it takes no space, changes no box and cannot shift a row -
   and `display: none` on a load error simply uncovers the letter that was underneath it all
   along. The circle keeps `overflow: visible` so the crown may overhang it, which is why the
   radius is on the image too: the picture clips ITSELF. */
.lobby-card-portrait {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border-radius: 50%;
  object-fit: cover;
}

/* The name is the row's whole point, so it TAKES the free space rather than merely being
   allowed to keep it - the tag is `margin-left: auto` and would otherwise leave the name at
   its content width and shrink it first when the two no longer fit. */
.lobby-card-name {
  flex: 1 1 auto;
  min-width: 0;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}

.lobby-card-tag {
  margin-left: auto;
  flex: 0 0 auto;
  font-size: 10px;
  font-weight: 700;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--lobby-text-dim);
  white-space: nowrap;
}

/* Under ~224px of column the grip, the avatar, their gaps and a tag as long as "YOU · HOST"
   leave the name nothing - it ellipsised to a character, or to nothing at all, on a phone and
   in any 1000-1270px window. The tag is the part that goes: `.lobby-card-self` already says
   which card is yours in the team colour, and a nameless roster row is worthless. */
@container (max-width: 224px) {
  .lobby-card-tag {
    display: none;
  }
}

/* Narrower still - a 4-per-team roster on a phone - and the grip goes too (it is only ever on
   your own card). It is an affordance for a gesture that works without it (the whole card is
   the drag handle, and `.lobby-card-self` is what marks the one you can move); the name is
   information. The rule is on the class, so it holds wherever in the row the grip sits. */
@container (max-width: 160px) {
  .lobby-card-grip {
    display: none;
  }
}

/* Engines without container queries get the same call made coarsely, on the viewport: above
   1280px the columns are wide enough at every shell width, below it they may not be, so the
   tag is simply dropped. Losing a tag that would have fitted is the safe way to be wrong. */
@supports not (container-type: inline-size) {
  @media (max-width: 1279px) {
    .lobby-card-tag {
      display: none;
    }
  }
}

.lobby-card-empty {
  color: var(--lobby-text-dim);
  font-weight: 500;
  border: 1.5px dashed rgba(255, 255, 255, 0.22);
  background: transparent;
  border: 1.5px dashed rgba(255, 255, 255, 0.3);
}

.lobby-card-empty .lobby-card-avatar {
  background: transparent;
  border: 1.5px dashed rgba(255, 255, 255, 0.3);
}

/* ---- v1.9 seat states ----
   The two things an unoccupied row can be: an On seat (a bot at kickoff, drawn as a real bot
   card) and an Off seat. Both keep .lobby-card and therefore its fixed height, so adding or
   removing a seat changes what a row says and never where the rows are. */

/* An empty On seat: a filled, team-tinted card, so the row reads as a participant the way a
   player card does - it IS one at kickoff - rather than as an empty seat. */
.lobby-card-bot {
  color: var(--lobby-text);
  background: rgba(255, 255, 255, 0.09);
}

@supports (color: color-mix(in srgb, red 50%, white)) {
  .lobby-card-bot {
    background: linear-gradient(
      90deg,
      color-mix(in srgb, var(--lobby-team, #232838) 24%, transparent),
      color-mix(in srgb, var(--lobby-team, #232838) 5%, transparent)
    );
    border-color: color-mix(in srgb, var(--lobby-team, #232838) 55%, transparent);
  }
}

/* The 🤖 face of an empty On seat, sized to sit inside the same circle a human's initial
   uses. Line-height 1 and the flex centring above are what keep the glyph from riding high:
   an emoji's own box is taller than its ink. */
.lobby-card-avatar-bot {
  font-size: 14px;
  line-height: 1;
  letter-spacing: 0;
}

/* Name over tag: the seat's tag is a sentence ("open seat - drag a player here"), which beside
   the name would eat the column. Stacked, both lines live inside the card's fixed height. */
.lobby-card-lines {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 1px;
}

/* Not .lobby-card-tag: that one is uppercase, tracked and dropped by the narrow-column
   container query - this line is the instruction, so it stays and simply ellipsises. */
.lobby-card-sub {
  font-size: 9.5px;
  font-weight: 500;
  line-height: 1.25;
  color: var(--lobby-text-dim);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* Off: the seat is not in the match at all. For the host the whole card is the "+ Add seat"
   button (dashed ghost, big centred label); a guest gets the same card, dimmed and inert. */
.lobby-card-off {
  color: var(--lobby-text-dim);
  font-weight: 700;
  background: transparent;
  border: 1.5px dashed rgba(255, 255, 255, 0.22);
  justify-content: center;
  opacity: 0.75;
}

.lobby-card-off-label {
  font-size: 14px;
  letter-spacing: 0.02em;
}

.lobby-card-off:not(.lobby-card-toggle) {
  opacity: 0.5;
  font-weight: 500;
}

/* An interactive seat is a real <button> - keyboard focusable, and a real click listener rather
   than a pointer gesture - so the panel's own type and colour have to be handed back to it. */
button.lobby-card {
  font-family: inherit;
  color: inherit;
  width: 100%;
  flex: 0 0 auto;
  -webkit-appearance: none;
  appearance: none;
}

/* "Make it really obvious" (Jordan): the pointer, and a lit card under the pointer or the focus
   ring. The hover skin is deliberately the SAME for both seats - one affordance, whatever the
   row currently says - and it lifts the Off card out of its dim, which is the state that most
   needs to look reachable. */
.lobby-card-toggle {
  cursor: pointer;
  transition: background 0.12s ease, border-color 0.12s ease, opacity 0.12s ease, transform 0.12s ease;
}

.lobby-card-toggle:hover,
.lobby-card-toggle:focus-visible {
  background: rgba(255, 255, 255, 0.16);
  border-color: rgba(255, 255, 255, 0.75);
  color: var(--lobby-text);
  opacity: 1;
  transform: translateY(-1px);
}

.lobby-card-toggle:focus-visible {
  outline: 2px solid #ffffff;
  outline-offset: 2px;
}

.lobby-card-toggle:active {
  transform: translateY(0) scale(0.985);
}

.lobby-card-toggle:hover .lobby-card-x,
.lobby-card-toggle:focus-visible .lobby-card-x {
  opacity: 1;
  color: var(--lobby-text);
}

/* The remove mark on a bot card. Pushed right by its own auto margin; the whole card is the
   button, so this is a mark and not a nested control. */
.lobby-card-x {
  margin-left: auto;
  flex: 0 0 auto;
  display: flex;
  opacity: 0.75;
  color: var(--lobby-text-dim);
}

.lobby-card-x-icon {
  width: 14px;
  height: 14px;
}

/* You. Lit in your own side's colour so the card you can drag is the obvious one. */
.lobby-card-self {
  background: rgba(255, 255, 255, 0.16);
  border-color: rgba(255, 255, 255, 0.85);
}

@supports (color: color-mix(in srgb, red 50%, white)) {
  .lobby-card-self {
    background: linear-gradient(
      90deg,
      color-mix(in srgb, var(--lobby-team, #232838) 34%, transparent),
      color-mix(in srgb, var(--lobby-team, #232838) 6%, transparent)
    );
    border-color: color-mix(in srgb, var(--lobby-team, #232838) 55%, #ffffff);
    box-shadow: 0 0 24px color-mix(in srgb, var(--lobby-team, #232838) 35%, transparent);
  }
}

.lobby-card-draggable {
  cursor: grab;
  touch-action: none;
  user-select: none;
}

.lobby-card-draggable:hover {
  filter: brightness(1.15);
}

.lobby-card-dragging-source {
  opacity: 0.35;
}

/* Lives on document.body during a drag, so it inherits none of the overlay's rules - it needs
   its own box-sizing, and buildPlayerCard puts --lobby-team on the card itself (not only on
   the column) so the clone keeps its side's colour. Font and text colour must be restated
   here for the same reason (they are inherited from #lobby-overlay, which the ghost is no
   longer inside - without them the clone falls back to the browser serif default), and the
   card's 5%-white background is swapped for a near-opaque panel tone so the ghost reads as
   a solid card floating above the page instead of a transparent outline. */
.lobby-card-ghost {
  position: fixed;
  z-index: 50;
  box-sizing: border-box;
  pointer-events: none;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  color: var(--lobby-text);
  background: #1c1e2e;
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
  cursor: grabbing;
}

/* Keep the self-card's team tint on the ghost, but mixed over the opaque panel tone instead
   of over transparent (the .lobby-card-self gradient this overrides was designed to sit on
   the team panel, not to float over arbitrary page content). */
@supports (color: color-mix(in srgb, red 50%, white)) {
  .lobby-card-ghost {
    background: linear-gradient(
      90deg,
      color-mix(in srgb, var(--lobby-team, #232838) 34%, #141622),
      color-mix(in srgb, var(--lobby-team, #232838) 6%, #141622)
    );
  }
}

/* ---- v1.11 overflow columns + the spectator bench ----
   Every class below is attached by lobby.js only for a game that sent `bench: { enabled: true }`
   (the crown only for one that sent `hostPid`), so a game on neither renders the v1.10 lobby
   untouched - no reserved height, no dashed rows, no chip and no zone. */

/* The column reserves COL_MAX rows whatever its seat count is: the space under the seats is a
   live drop target, and "this side can take more" is readable before the drag starts. The row
   count arrives as --lobby-col-rows (set per column by lobby.js) rather than being written here -
   no team size may live in this stylesheet. The 6px is .lobby-team-slots' own gap. */
.lobby-slots-tall {
  min-height: calc(var(--lobby-col-rows, 5) * var(--lobby-card-h) + (var(--lobby-col-rows, 5) - 1) * 6px);
}

/* A side holding more humans than it has seats. Amber, not the danger red of .lobby-col-error:
   this state is legal and reversible, it just cannot start. It yields while a drag is over the
   column, so the drop-target/blocked answer under the pointer is never argued with. */
.lobby-team-col.lobby-col-over:not(.lobby-drop-target):not(.lobby-drop-blocked) {
  border-color: rgba(255, 193, 84, 0.75);
}

.lobby-col-over .lobby-team-count {
  color: #ffc154;
}

/* The column's one explanation, centred and pinned to the column's floor by its own auto margin
   (.lobby-team-slots is a flex column). Short by design: the disabled Start button says only
   that it cannot start, because a second copy of this would wrap the button and move everything
   under it. */
.lobby-over-chip {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 7px;
  align-self: center;
  margin-top: auto;
  padding: 7px 10px;
  border: 1px dashed rgba(255, 193, 84, 0.45);
  border-radius: 10px;
  background: rgba(255, 193, 84, 0.1);
  font-size: 11px;
  font-weight: 600;
  line-height: 1.3;
  color: #ffc154;
}

/* The row that IS the overflow: the neutral card, unfilled, with a dashed edge. "In the match"
   and "one too many" must not be a matter of shade, which is what the solid fill below buys. */
.lobby-card-over {
  border-style: dashed;
}

/* A row the side actually has a seat for. Brighter than the .lobby-card-self gradient it
   overrides, exactly as the mock renders it: on a bench-enabled game your own card is marked by
   its glow, its grip and its YOU tag rather than by being the only filled one.
   Excluded from the drag ghost by name: this block sits AFTER .lobby-card-ghost at equal
   specificity, so without the :not() the clone of an in-seat card would take this fill - mixed
   over `transparent` - and lose the opaque panel tone the ghost is given above for the reason
   spelled out there (it floats over the Unity canvas, not over the team panel). The ghost keeps
   its own background; only cards still in a column are filled. */
.lobby-card-valid:not(.lobby-card-ghost) {
  background: rgba(255, 255, 255, 0.12);
}

@supports (color: color-mix(in srgb, red 50%, white)) {
  .lobby-card-valid:not(.lobby-card-ghost) {
    background: linear-gradient(
      90deg,
      color-mix(in srgb, var(--lobby-team, #232838) 46%, transparent),
      color-mix(in srgb, var(--lobby-team, #232838) 16%, transparent)
    );
  }

  /* The border, unlike the fill, is safe on the ghost and wanted there: it is what says which
     side the card being dragged came from. Left unscoped for that reason. */
  .lobby-card-valid {
    border-color: color-mix(in srgb, var(--lobby-team, #232838) 75%, transparent);
  }
}

/* A bench card is the same component with no side: the neutral tint rides in on --lobby-team
   (set inline by lobby.js, so no colour is named here either) and it sits back a little,
   because it is not in the match. */
.lobby-card-bench {
  opacity: 0.9;
}

/* The host's mark. On the avatar rather than beside the name: the narrow-column container query
   drops the tag first, and "this one hosts" may not be the thing that goes. Absolutely
   positioned, so it adds no width and cannot push the name - the avatar is its containing block
   and keeps its overflow visible so the badge may sit above the circle. Both rules ride into the
   drag ghost with the clone, which is the only place the avatar leaves the card. */
.lobby-card-avatar {
  position: relative;
  overflow: visible;
}

.lobby-crown {
  position: absolute;
  top: -8px;
  left: 50%;
  transform: translateX(-50%) rotate(-4deg);
  font-size: 12px;
  line-height: 1;
  filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.7));
}

/* ---- voice presence on the roster (Jordan, 2026-09-02 — the Phase C marks return) ----
   Both marks are drawn ONLY when voice.js has an answer for the pid, so a page without voice
   renders the exact pre-voice card (the flag-off byte guard depends on that).

   The speaking ring: mint, the OG platform's colour for "talking now". A box-shadow rather than
   a border so the 24px circle keeps its size, exactly like the voice rail's own avatar ring. */
.lobby-card-avatar-speaking {
  box-shadow:
    0 0 0 2px #35d49b,
    0 0 8px rgba(53, 212, 155, 0.55);
}

/* The badge: a tiny circled icon over the avatar's bottom-right corner. Absolutely positioned
   like the crown, so it adds no width and cannot push the name; the dark halo ring is what
   separates it from whatever team tint the avatar wears. Red mic-off = in the call but muted,
   amber ✕ = not in the voice call at all. */
.lobby-voice-badge {
  position: absolute;
  right: -4px;
  bottom: -4px;
  width: 13px;
  height: 13px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  box-shadow: 0 0 0 1.5px rgba(9, 11, 20, 0.85);
}

.lobby-voice-badge svg {
  width: 8px;
  height: 8px;
  display: block;
}

.lobby-voice-badge-muted {
  background: #e5484d;
  color: #ffffff;
}

.lobby-voice-badge-off {
  background: #f0b429;
  color: #241a05;
}

/* The third zone, under both columns and as wide as the pair. Dashed and untinted: it belongs to
   neither side, and the dashes say "droppable" the way an Off seat's do. */
.lobby-bench {
  margin-top: 12px;
  padding: 12px 14px 14px;
  border: 2px dashed rgba(255, 255, 255, 0.16);
  border-radius: var(--lobby-team-radius);
  background: rgba(255, 255, 255, 0.025);
  transition: background 0.15s ease, border-color 0.15s ease;
}

/* Same instant answer a column gives, in the zone's own neutral: it has no team colour to light
   up with. */
.lobby-bench.lobby-drop-target {
  transition: none;
  border-color: rgba(255, 255, 255, 0.7);
  background: rgba(255, 255, 255, 0.08);
}

.lobby-bench-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 8px;
  padding: 0 2px 8px;
}

.lobby-bench-title {
  font-family: var(--lobby-display);
  font-weight: var(--lobby-display-weight);
  font-size: 11px;
  letter-spacing: 0.04em;
  color: var(--lobby-text-dim);
}

.lobby-bench-sub {
  font-size: 10.5px;
  color: var(--lobby-text-dim);
}

/* Two cards per row (the zone spans both columns), and past ~3.5 rows it scrolls instead of
   growing the panel forever - the half-cut row is what says there are more. A query container
   of its own, like .lobby-team-slots: a bench card is half the zone wide, so the narrow-column
   rules that drop the grip have to be able to measure it. */
.lobby-bench-row {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 6px;
  max-height: calc(3.5 * var(--lobby-card-h) + 3 * 6px);
  overflow-y: auto;
  scrollbar-width: thin;
  scrollbar-color: rgba(255, 255, 255, 0.25) transparent;
  container-type: inline-size;
}

/* An empty bench still has to say what dropping there does, so the zone is never a bare box. */
.lobby-bench-empty {
  padding: 6px 2px 2px;
  font-size: 11px;
  color: var(--lobby-text-dim);
  opacity: 0.7;
}

/* ---- v1.24: the WATCHING strip ----

   Watchers live inside the bench zone but under a rule of their own, because they are not the same
   kind of thing as a benched player: they hold no roster seat, they can never take the crown, and
   the host should not be dragging them onto a team by reflex. A hairline divider and a dimmer
   treatment say "audience" without inventing a second box beside the bench. */
.lobby-watch {
  margin-top: 10px;
  padding-top: 10px;
  border-top: 1px solid rgba(255, 255, 255, 0.1);
}

/* One notch dimmer than the bench's own cards: present, legible, plainly not in the match. */
.lobby-watch .lobby-bench-row {
  opacity: 0.82;
  /* Watchers can outnumber players, so this strip scrolls sooner than the bench above it rather
     than pushing the Start button off a short screen. */
  max-height: calc(2.5 * var(--lobby-card-h) + 2 * 6px);
}

.lobby-watch .lobby-bench-head {
  padding-bottom: 6px;
}

/* ---- "HOW TO PLAY" (LOBBY_CONFIG description / instructions / controls) ----

   The left column's content, under the optional gameplay video: an eyebrow, the one-line
   pitch, the rules as a tight arrowed list, then the controls drawn as the keys and the mouse
   they actually are. No card of its own - the left column IS open space, and a panel there
   would fight the two team panels opposite. */
.lobby-howto {
  display: flex;
  flex-direction: column;
  gap: 14px;
  min-width: 0;
}

.lobby-howto-head {
  font-family: var(--lobby-display);
  font-size: 11px;
  line-height: 1.4;
  letter-spacing: 0.02em;
  color: var(--lobby-text-dim);
}

.lobby-howto-desc {
  font-size: clamp(15px, 1.4vw, 18px);
  line-height: 1.3;
  font-weight: 600;
  color: var(--lobby-text);
  text-wrap: pretty;
}

/* Tight list: the default UA margins and bullet would both fight the arrow glyphs. */
.lobby-howto-list {
  display: flex;
  flex-direction: column;
  gap: 10px;
  margin: 0;
  padding: 0;
  list-style: none;
}

.lobby-howto-item {
  display: flex;
  align-items: flex-start;
  gap: 12px;
  font-size: 15px;
  line-height: 1.45;
  color: var(--lobby-text-dim);
  text-wrap: pretty;
  overflow-wrap: anywhere;
}

.lobby-howto-arrow {
  width: 14px;
  height: 14px;
  flex: 0 0 auto;
  margin-top: 5px;
  color: var(--lobby-accent);
}

.lobby-howto-controls {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  gap: 12px 40px;
  margin-top: 4px;
}

/* ---- a control, drawn ----

   One cluster per LOBBY_CONFIG.controls entry, with the entry's `description` as the label
   BENEATH it. `key` picks the drawing: "wasd" / "arrows" -> a keycap cluster, "mouse" -> the
   mouse with its left button lit in the accent. Any other key falls back to the text chip
   below, so a game with an exotic control still renders something truthful. */
.lobby-control {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10px;
}

.lobby-control-label {
  font-size: 12px;
  font-weight: 700;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: var(--lobby-text-dim);
  text-align: center;
}

.lobby-keys {
  display: grid;
  grid-template-columns: repeat(3, 36px);
  gap: 4px;
}

.lobby-key {
  width: 36px;
  height: 36px;
  border-radius: 8px;
  border: 1px solid rgba(255, 255, 255, 0.28);
  background: linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0.04));
  box-shadow: 0 2px 0 rgba(255, 255, 255, 0.14) inset, 0 -3px 0 rgba(0, 0, 0, 0.45) inset,
    0 4px 10px rgba(0, 0, 0, 0.35);
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 14px;
  font-weight: 700;
  line-height: 1;
  color: #ffffff;
}

/* The empty cells above A and D in the cluster. */
.lobby-key-gap {
  width: 36px;
  height: 36px;
}

.lobby-mouse {
  width: 30px;
  height: 44px;
  display: block;
}

.lobby-mouse-btn {
  fill: var(--lobby-accent);
}

.lobby-howto-chip {
  display: flex;
  align-items: baseline;
  gap: 5px;
  min-width: 0;
  padding: 5px 10px;
  border-radius: 999px;
  border: 1px solid var(--lobby-panel-border);
  background: rgba(255, 255, 255, 0.06);
  font-size: 12px;
  line-height: 1.3;
  color: var(--lobby-text-dim);
}

.lobby-howto-key {
  font-weight: 800;
  letter-spacing: 0.06em;
  white-space: nowrap;
  color: var(--lobby-text);
}

.lobby-howto-act {
  min-width: 0;
  overflow-wrap: anywhere;
}

/* ---- v1.12 §5a: the claim-host button (convex mode only) ----
   v1.14 moved it into the panel's header; v1.16 moved that header off this bundle altogether. The
   button is drawn by the page's own header now (web/shell/src/game/GameHeader.tsx, styled in
   src/game/game.css) and this file has nothing to say about it. */

/* ---- v1.14: the Rejoin chip ----
   The results screen's one control, borrowed for the lobby's Start slot while this tab is sitting
   a match out. Same skin, but sized off the panel rather than off the viewport: the results chip's
   clamps are vw-based, which inside a fixed-width column would be a different button on every
   window. It fills the CTA's box exactly, so the lobby is the same height with it as without. */
.lobby-rejoin {
  align-self: stretch;
  width: 100%;
  min-height: var(--lobby-cta-big-h);
  margin-top: 0;
  padding: 12px 22px;
  font-size: clamp(13px, 1.1vw, 16px);
}

/* ---- v1.10: the bot-difficulty row ----
   Directly under the two team columns, above the invite pill: one line, label and control
   right-aligned as a GROUP (the label reads as the field's own caption, so it sits immediately
   left of it rather than across the row). It wears the roster CARD's skin (same radius, same
   1.5px hairline, same translucent fill) rather than inventing a surface, so it reads as part
   of the column stack above it and not as a form bolted under it. Flat and front-on, like
   everything else here. */
.lobby-bots-row {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 12px;
  min-height: 44px;
  padding: 0 4px;
  /* Hug the team columns above; move the freed breathing room below, before the invite pill.
     Half the column gap each way keeps the total rhythm unchanged. */
  margin-top: calc(-0.5 * clamp(18px, 2.6vh, 26px));
  margin-bottom: calc(0.5 * clamp(18px, 2.6vh, 26px));
}

.lobby-bots-label {
  font-size: 13px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--lobby-text-dim);
}

/* A guest's read-only value: the same words in the same place the select's would be, so the
   row does not change shape between the peer that owns the setting and the peers that do not.
   It therefore borrows the control's box metrics (width, height, text inset) without its skin —
   under a right-aligned group that is what keeps the label at the same x on both peers. */
.lobby-bots-value {
  display: inline-flex;
  align-items: center;
  min-width: 156px;
  height: 40px;
  padding: 0 14px;
  font-size: 14px;
  font-weight: 700;
  color: var(--lobby-text);
}

/* The control is the box; the <select> inside it is transparent and stripped of its OS chrome,
   which is what keeps a white system dropdown off a dark panel. The chevron is drawn by us and
   sits on top, pointer-events off so the whole box is still the select's own click target. */
.lobby-bots-control {
  position: relative;
  display: inline-flex;
  align-items: center;
  min-width: 156px;
  height: 40px;
  border-radius: var(--lobby-card-radius);
  border: 1.5px solid rgba(255, 255, 255, 0.1);
  background: rgba(255, 255, 255, 0.05);
  transition: background 0.12s ease, border-color 0.12s ease;
}

.lobby-bots-control:hover {
  background: rgba(255, 255, 255, 0.1);
  border-color: rgba(255, 255, 255, 0.28);
}

.lobby-bots-select {
  -webkit-appearance: none;
  appearance: none;
  width: 100%;
  height: 100%;
  margin: 0;
  padding: 0 34px 0 14px;
  border: none;
  border-radius: inherit;
  background: transparent;
  color: var(--lobby-text);
  font-family: inherit;
  font-size: 14px;
  font-weight: 700;
  line-height: 1;
  cursor: pointer;
  /* The one lever a page has over the OPEN dropdown: it is painted by the OS, and this is what
     makes it dark rather than white. The option colours below cover the engines that ignore it. */
  color-scheme: dark;
}

.lobby-bots-select:focus-visible {
  outline: 2px solid var(--lobby-accent);
  outline-offset: 2px;
}

.lobby-bots-select option {
  /* --lobby-panel's own colour, opaque: an option's background is painted by the OS and cannot
     be translucent, so this is the one place the panel's tint is spelled out rather than reused. */
  background-color: #12131e;
  color: var(--lobby-text);
}

.lobby-bots-chevron {
  position: absolute;
  right: 12px;
  display: flex;
  pointer-events: none;
  color: var(--lobby-text-dim);
}

.lobby-bots-chevron svg {
  width: 14px;
  height: 14px;
}

/* The invite row is one pill: link glyph, INVITE, the room URL, and a white COPY LINK button
   sitting inside it. The URL stays a read-only <input> so it is still selectable by hand when
   the clipboard API is unavailable; it just stops looking like a form field. */
.lobby-share-row {
  display: flex;
  align-items: center;
  gap: 12px;
  height: 64px;
  padding: 0 8px 0 18px;
  border-radius: 999px;
  border: 1.5px solid var(--lobby-panel-border);
  background: rgba(255, 255, 255, 0.05);
  container-type: inline-size;
}

/* White chrome on the invite row (user direction 2026-08-23) - the accent stays on the CTAs. */
.lobby-share-row {
  border-color: rgba(255, 255, 255, 0.35);
}

.lobby-share-icon {
  width: 18px;
  height: 18px;
  flex: 0 0 auto;
  color: rgba(255, 255, 255, 0.9);
}

/* Middle ellipsis: the head span gives way (ellipsised) while the tail - "?room=<code>",
   the entire reason the pill exists - is never cut, so the start AND the end of the link
   both show. Selecting the text and copying yields the FULL link, ellipsis or not: the
   box's copy handler rewrites the clipboard (see lobby.js). */
.lobby-share-link {
  flex: 1;
  min-width: 0;
  display: flex;
  align-items: center;
  color: var(--lobby-text);
  font-size: 13px;
  font-weight: 600;
  cursor: text;
  user-select: all;
  -webkit-user-select: all;
}

.lobby-share-head {
  flex: 0 1 auto;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.lobby-share-tail {
  flex: 0 0 auto;
  white-space: nowrap;
}

.lobby-copy-btn {
  flex-shrink: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  height: 48px;
  /* Wide enough for the longer of its two labels ("Invite link" / "Copied!"), so the flip
     never changes the button's width - no layout shift in the row. */
  min-width: 148px;
  padding: 0 18px;
  border-radius: 999px;
  background: #ffffff;
  color: var(--lobby-cta-ink);
  font-size: 12px;
  font-weight: 800;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  white-space: nowrap;
}

.lobby-copy-btn:hover:not(:disabled) {
  background: #ffffff;
  filter: brightness(0.92);
}

.lobby-copy-btn svg {
  width: 14px;
  height: 14px;
  flex: 0 0 auto;
}

/* ---- v1.15: the body-level controls, and the font bug they all had ----

   Every control lobby.js injects on document.body - the spectating chip and the exit-confirm modal
   (v1.22 took the mute toggle and the exit button off the body and into the voice rail) - is
   OUTSIDE #lobby-overlay, which is the only place this stylesheet declares a font-family.
   font-family is inherited, so a body-level node falls
   back to the document default: in Chrome that is a serif, which is the "Times New Roman"
   Jordan read on the injected buttons. `.lobby-float` is the shared class every one of them
   carries, and this is where the house stack is restated for them (the same fix, and the same
   reason, as .lobby-card-ghost's above).

   The stack is the DISPLAY one (Unbounded): these are chrome, not body copy, and they sit
   beside .lobby-cta, whose --lobby-cta-font is the same face. */
.lobby-float {
  font-family: var(--lobby-display);
}

/* ---- 2026-08-28: the round chrome buttons wear ONE skin, so it is ONE class ----

   This was the mute toggle and the exit button, the two controls that sat over the live canvas for
   a whole match, one in each top corner. Jordan's ruling then: they are the same button in two
   places, so the plate lives on a class both nodes carry rather than as two copies of the same
   values that drift apart the next time one of them is touched.

   v1.22 moved both of those into the voice rail, which brings its own skin - so the class survives
   for its OTHER wearer: the shared corner back button (src/BackButton.tsx, back-button.css), which
   was styled to match them and would otherwise be re-stating all of this. */
.lobby-round-btn {
  width: 44px;
  height: 44px;
  min-width: 44px;
  min-height: 44px;
  padding: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 999px;
  border: 1px solid var(--lobby-panel-border);
  background: rgba(5, 6, 11, 0.9);
  color: var(--lobby-text);
  font-size: 20px;
  line-height: 1;
  cursor: pointer;
  /* No backdrop-filter: one of these can overlay the canvas for a whole match, and blur forces a
     per-frame compositor readback of the game's output. */
}

.lobby-round-btn:hover {
  background: rgba(18, 19, 30, 0.85);
}

/* The stroke icon a round button holds. currentColor throughout, so the plate above is the only
   thing that colours it. */
.lobby-round-btn svg {
  width: 20px;
  height: 20px;
  flex: 0 0 auto;
}

/* ---- v1.15 §1: the spectating chip ----
   The third body-level control, and the only one that is not a button: a benched player watching a
   match gets one line saying so, top-centre, over the canvas. Same backdrop-filter ban as its two
   neighbours (it is up for the whole match), plus pointer-events: none - it sits over the play area
   and may not eat a click. Hidden by default; every game gets it from renderSpectateChip() with no
   capability flag, because nothing about it is game-specific.
   v1.15 §5b re-styles it away from the two buttons on purpose: NOT a floating pill but a tab hung
   off the top edge of the window (top: 0, bottom corners only, no top border), in the one colour
   pair no game's canvas uses - white on black - so "you are watching, not playing" reads instantly
   and never looks like something to press. */
.lobby-spectating {
  position: fixed;
  top: 0;
  left: 50%;
  transform: translateX(-50%);
  z-index: 60;
  height: 34px;
  padding: 0 16px;
  display: none;
  align-items: center;
  justify-content: center;
  border-radius: 0 0 12px 12px;
  border: 2px solid #000;
  border-top: none;
  background: #fff;
  color: #000;
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  line-height: 1;
  white-space: nowrap;
  pointer-events: none;
}

/* ---- v1.17 §3 (as amended 2026-08-29): the partner embed's failure/retry layer ----

   There is no splash. Jordan's ruling: the old platform explicitly forced partner users past every
   join gate, so the page joins itself on load and this layer is the ONE thing embed mode can draw
   over the lobby - the state where that automatic join failed and nothing is in flight to fix it.

   A full-viewport layer OVER #lobby-overlay and under the page header (--ig-header-h is the shell's
   measured band; the 0px fallback is for a page that mounts no header, e.g. tools/mock-lobby). The
   scrim is deliberately light: the lobby is LIVE underneath, so the player can still see who is in
   this ROAM room while the retry is offered.

   Fourth body-level float, and the only one that does not exist at all with the mode off:
   renderEmbedFailure() returns before it can build a node unless ?partner=roam is on a convex-mode
   page. The BOX is inert and only the button inside it takes a press (§3: one interactive element).
   No backdrop-filter, for .lobby-round-btn's reason - though this one is only up before the match,
   so it is the cheap half of that rule. */
.lobby-embed-fail {
  position: fixed;
  top: var(--ig-header-h, 0px);
  right: 0;
  bottom: 0;
  left: 0;
  z-index: 65;
  box-sizing: border-box;
  margin: 0;
  padding: 24px;
  border: 0;
  display: none;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 16px;
  background: rgba(4, 6, 13, 0.62);
  color: var(--lobby-text);
  text-align: center;
}

.lobby-embed-fail-message {
  font-family: var(--lobby-display);
  font-weight: var(--lobby-display-weight);
  font-size: clamp(20px, 3.4vw, 32px);
  line-height: 1.15;
  letter-spacing: 0.01em;
}

/* The one control in this state. The lobby's own pill, restated rather than reusing .lobby-cta:
   that class is the entry screen's full-width CTA and carries its block sizing with it. */
.lobby-embed-fail-retry {
  appearance: none;
  border: 0;
  margin: 0;
  padding: 12px 28px;
  border-radius: 999px;
  background: var(--lobby-accent);
  color: #fff;
  font-family: var(--lobby-display);
  font-weight: 700;
  font-size: 15px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  line-height: 1;
  cursor: pointer;
}

.lobby-embed-fail-retry:hover {
  filter: brightness(1.08);
}

/* ---- v1.15 §2: the host's exit-confirm modal ----

   Jordan's spec: 50% black veil, blurred background, a one-line question and two EQUAL-WIDTH
   answers in the lobby's own font.

   backdrop-filter, here and only here. The ban stated on .lobby-round-btn (the shared plate the mute
   toggle and the exit button both wear) is
   about a PERSISTENT overlay: blur over the live WebGL canvas costs a compositor readback of the
   game's output every frame the node exists, and those two exist for the whole match. This veil is
   built when the question is asked and removed when it is answered, so it is measured in seconds -
   and the always-visible controls under it stay unblurred, which is what the ban is protecting. */
.lobby-modal-veil {
  position: fixed;
  inset: 0;
  z-index: 70;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
  box-sizing: border-box;
  background: rgba(0, 0, 0, 0.5);
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
}

.lobby-modal {
  width: min(420px, 100%);
  box-sizing: border-box;
  padding: 28px 28px 24px;
  border-radius: var(--lobby-radius);
  border: 1px solid var(--lobby-panel-border);
  background: #12131e;
  color: var(--lobby-text);
  box-shadow: 0 24px 60px rgba(0, 0, 0, 0.55);
  text-align: center;
}

.lobby-modal * {
  box-sizing: border-box;
}

.lobby-modal-question {
  margin-bottom: 22px;
  font-size: clamp(15px, 1.6vw, 18px);
  font-weight: 700;
  line-height: 1.35;
}

/* 1fr 1fr IS the "equal width" requirement: neither answer is the bigger target, whatever the two
   labels happen to measure. */
.lobby-modal-actions {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 12px;
}

/* The lobby's button family (.lobby-btn's shape), sized for a dialog rather than for a panel. The
   font is restated for the same reason .lobby-float's is - this subtree is on document.body. */
.lobby-modal-btn {
  min-height: 46px;
  padding: 12px 16px;
  border: 1px solid var(--lobby-panel-border);
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.08);
  color: var(--lobby-text);
  font-family: var(--lobby-display);
  font-size: 12px;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  line-height: 1.2;
  cursor: pointer;
  transition: background 0.15s ease, filter 0.15s ease;
}

.lobby-modal-btn:hover {
  background: rgba(255, 255, 255, 0.16);
}

/* The destructive answer reads as one - the danger token, not the primary-action gradient, so the
   dialog never suggests that ending the match is the thing to do. */
.lobby-modal-btn-danger {
  border-color: transparent;
  background: var(--lobby-danger);
  color: #1a0708;
}

.lobby-modal-btn-danger:hover {
  background: var(--lobby-danger);
  filter: brightness(1.08);
}

/* ---- v1.22: the game-switch modal (gameModal.ts) ----

   The home-page picker's card grammar (web/shell/src/home.css .card) on the lobby's own tokens,
   inside the same veil the exit confirm uses. Overrides on .lobby-modal because the two dialogs
   share that base class: this one is a grid of cards, not a centred 420px question.

   Flat and front-on: no tilt, no perspective (standing rule). */
/* Full SCREEN (Jordan, 2026-09-02, superseding the 2026-09-01 full-width ruling): the veil's own
   24px padding is the only inset and the modal fills the viewport's height too — a sheet, not a
   content-hugging stripe. Flex column so the head pins to the top, the grid takes the rest, and
   the guest note sits under it. */
.lobby-game-modal {
  width: 100%;
  height: calc(100dvh - 48px);
  max-height: none;
  overflow: auto;
  padding: 28px 28px 24px;
  text-align: left;
  display: flex;
  flex-direction: column;
}

/* The quick fade, both directions (Jordan, 2026-08-30). On the VEIL, so the tint, the blur and
   the card go as one. 150ms here IS gameModal.ts's MODAL_FADE_MS — the leaving node is removed on
   that timer. The backdrop blur is animated explicitly because element opacity does not scale a
   backdrop-filter: without it the blur would pop on at full strength under a still-transparent
   tint, and pop off at removal. Reduced-motion gets no animation, and gameModal.ts skips the
   leaving class entirely (instant removal) for that case and for hidden tabs. */
.lobby-game-veil {
  animation: lobby-game-veil-in 0.15s ease;
}

@keyframes lobby-game-veil-in {
  from {
    opacity: 0;
    backdrop-filter: blur(0);
    -webkit-backdrop-filter: blur(0);
  }
}

.lobby-game-veil.lobby-game-modal-leaving {
  opacity: 0;
  backdrop-filter: blur(0);
  -webkit-backdrop-filter: blur(0);
  transition: opacity 0.15s ease, backdrop-filter 0.15s ease, -webkit-backdrop-filter 0.15s ease;
  /* Dying, not dismissable twice: nothing under the fading veil may eat a click. */
  pointer-events: none;
}

@media (prefers-reduced-motion: reduce) {
  .lobby-game-veil {
    animation: none;
  }

  .lobby-game-veil.lobby-game-modal-leaving {
    transition: none;
  }
}

.lobby-game-modal-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  margin-bottom: 18px;
}

.lobby-game-modal-title {
  font-family: var(--lobby-display);
  font-weight: var(--lobby-display-weight);
  font-size: 15px;
  letter-spacing: 0.1em;
  text-transform: uppercase;
}

.lobby-game-modal-close {
  width: 34px;
  height: 34px;
  flex: none;
  border: 1px solid var(--lobby-panel-border);
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.07);
  color: var(--lobby-text);
  font-size: 15px;
  line-height: 1;
  cursor: pointer;
}

.lobby-game-modal-close:hover {
  background: rgba(255, 255, 255, 0.15);
}

/* As many 300px-plus columns as the viewport affords — 4 on a wide desktop, 3 on a laptop, 2 on a
   tablet — rather than a fixed two. auto-fill (not auto-fit) so three games on a four-column
   screen stay card-sized with an empty fourth track instead of stretching to fill it. */
.lobby-game-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
  gap: 20px;
  /* Top-aligned rows (Jordan, 2026-09-02): the catalogue reads from the top of the sheet; once
     there are more rows than viewport the modal's overflow scrolls them. */
  flex: 1;
  align-content: start;
}

@media (max-width: 640px) {
  .lobby-game-grid {
    grid-template-columns: minmax(0, 1fr);
  }
}

/* One card. A <button> only when it is a pick target (the host's non-current games); the current
   card and every guest card are divs, so the shared rule restates the button resets. */
.lobby-game-card {
  display: flex;
  flex-direction: column;
  padding: 0;
  text-align: left;
  /* The overlay's body stack, restated: this subtree hangs off the veil, whose .lobby-float wears
     the DISPLAY face — right for the heading and the CTA (which restate it), wrong for a tagline. */
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  background: rgba(255, 255, 255, 0.045);
  border: 1px solid var(--lobby-panel-border);
  border-radius: 18px;
  overflow: hidden;
  color: var(--lobby-text);
  transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease;
}

button.lobby-game-card {
  cursor: pointer;
}

button.lobby-game-card:hover,
button.lobby-game-card:focus-visible {
  transform: translateY(-4px);
  border-color: color-mix(in srgb, var(--lobby-accent) 70%, transparent);
  box-shadow: 0 18px 44px rgba(0, 0, 0, 0.55),
    0 0 0 1px color-mix(in srgb, var(--lobby-accent) 35%, transparent),
    0 10px 60px color-mix(in srgb, var(--lobby-accent) 42%, transparent);
}

/* The current game: the accent ring says "you are here", and it is not a pick target. */
.lobby-game-card-on {
  border-color: color-mix(in srgb, var(--lobby-accent) 75%, transparent);
  box-shadow: 0 0 0 1px color-mix(in srgb, var(--lobby-accent) 55%, transparent),
    0 10px 50px color-mix(in srgb, var(--lobby-accent) 30%, transparent);
}

.lobby-game-card-art {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 16 / 10;
  object-fit: cover;
  background: #000;
  border-bottom: 1px solid var(--lobby-panel-border);
}

.lobby-game-card-body {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 12px;
  padding: 16px;
  flex: 1;
}

.lobby-game-card-title {
  font-family: var(--lobby-display);
  font-weight: var(--lobby-display-weight);
  font-size: 16px;
  line-height: 1.3;
  text-transform: uppercase;
  text-shadow: 0 0 18px color-mix(in srgb, var(--lobby-accent) 60%, transparent);
}

.lobby-game-card-tagline {
  font-size: 14px;
  line-height: 1.5;
  color: var(--lobby-text-dim);
}

.lobby-game-card-chip {
  font-size: 12px;
  font-weight: 700;
  letter-spacing: 0.04em;
  color: var(--lobby-text);
  background: color-mix(in srgb, var(--lobby-accent) 22%, transparent);
  border: 1px solid color-mix(in srgb, var(--lobby-accent) 60%, transparent);
  border-radius: 999px;
  padding: 5px 12px;
}

/* The pick CTA wears the house gradient (the home page's PLAY pill); the current badge is the
   quiet twin — accent outline, no gradient, so the card you cannot pick never looks like the
   thing to press. */
.lobby-game-card-cta,
.lobby-game-card-badge {
  font-family: var(--lobby-display);
  font-weight: var(--lobby-display-weight);
  font-size: 12px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  border-radius: 999px;
  padding: 12px 20px;
  margin-top: auto;
}

.lobby-game-card-cta {
  color: #ffffff;
  background: linear-gradient(90deg, var(--lobby-cta-grad-a, #ff2e3e), var(--lobby-cta-grad-b, #784bff));
  box-shadow: 0 0 24px color-mix(in srgb, var(--lobby-cta-grad-a, #ff2e3e) 50%, transparent);
}

.lobby-game-card-badge {
  color: color-mix(in srgb, var(--lobby-accent) 85%, #fff);
  border: 1px solid color-mix(in srgb, var(--lobby-accent) 60%, transparent);
  padding: 11px 19px;
}

.lobby-game-modal-note {
  margin-top: 16px;
  text-align: center;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  font-size: 13px;
  color: var(--lobby-text-dim);
}

@media (prefers-reduced-motion: reduce) {
  .lobby-game-card {
    transition: none;
  }

  button.lobby-game-card:hover,
  button.lobby-game-card:focus-visible {
    transform: none;
  }
}

/* Two columns need roughly 1000px to stay comfortable; below that they stack.

   The ACTIONABLE half goes first when they do. Side by side, the left column is reference
   material you read past; stacked, putting it first buries the name field - and, in the
   lobby, the roster, the invite row and the host's Start button - a full screen below the
   fold, so the one thing the screen exists for is off-screen at load. `order` moves the box
   only: the DOM order (aside, then body) is what a screen reader still follows. */
@media (max-width: 999px) {
  .lobby-columns {
    grid-template-columns: minmax(0, 1fr);
    max-width: var(--lobby-main-w);
    margin: 0 auto;
  }

  .lobby-body {
    order: -1;
  }
}

@media (max-width: 380px) {
  :root {
    --lobby-ended-headline-h: 72px;
  }

  .lobby-card {
    font-size: 13px;
    padding: 0 8px;
    gap: 7px;
  }

  .lobby-howto-desc {
    font-size: 14px;
  }

  .lobby-howto-item {
    font-size: 13px;
  }

  .lobby-share-row {
    padding-left: 12px;
  }

}

@media (prefers-reduced-motion: reduce) {
  .lobby-btn,
  .lobby-cta,
  .lobby-modal-btn,
  .lobby-team-col,
  .lobby-bench {
    transition: none;
  }

  .lobby-team-col.lobby-col-error {
    animation: none;
  }

  /* The toggle still lights up under the pointer, it just does not move or fade into it. */
  .lobby-card-toggle {
    transition: none;
  }

  .lobby-card-toggle:hover,
  .lobby-card-toggle:focus-visible,
  .lobby-card-toggle:active {
    transform: none;
  }

  /* Every page still changes, it just arrives instead of fading (T1-T3, T5). The overlay's
     transition goes too, so `.lobby-leaving` is instant and the match is not waited on. */
  #lobby-overlay .lobby-page-enter {
    opacity: 1;
    animation: none;
  }

  #lobby-overlay {
    transition: none;
  }

  /* The convex page fades go with them (T6/T7): the overlay arrives at full opacity, and the
     outgoing copy is never drawn at all - fading it out is the whole of what it is for, and
     `animation: none` would leave it standing on top of the live page instead. */
  #lobby-overlay.lobby-arriving {
    animation: none;
  }

  #lobby-overlay .lobby-ghost {
    display: none;
  }

  /* The results screen still assembles, it just arrives all at once. */
  .lobby-results-enter .lobby-results-winner,
  .lobby-results-enter .lobby-results-backdrop,
  .lobby-results-enter .lobby-results-frame,
  .lobby-results-enter .lobby-results-teamname,
  .lobby-results-enter .lobby-results-score,
  .lobby-results-enter .lobby-results-totals,
  .lobby-results-enter .lobby-results-row,
  .lobby-results-enter .lobby-super-card,
  .lobby-results-enter .lobby-ffa-head,
  .lobby-results-enter .lobby-results-return {
    opacity: 1;
    animation: none;
  }

  .lobby-results-return {
    transition: none;
  }
}

/* ============================================================================
   Contract addendum v1.20 — opt-in MULTI-TEAM lobby.
   Everything here is gated behind `.lobby-panel.lobby-wide` (render.tsx adds that class only
   when LOBBY_CONFIG.multiTeam is set) or behind the new `.lobby-mt-*` / `.lobby-setting` / ranked
   classes, none of which the two-team lobby emits — so bubble-blitz and astroball are untouched and
   the flag-off byte guard is unaffected. Ported from tools/artifacts/wordy-lobby-mock.html with the
   game-named `wordy-*` classes renamed to neutral `lobby-*` ones (no game may be named in here).
   ============================================================================ */

/* Full-width shell: the capped --lobby-shell-w opens up; the info column keeps its real styling but
   takes a fixed share instead of half, so matchmaking gets the room. Teams keep TODAY's columns,
   two per row; a wide viewport steps up to three at today's column width, so more teams wrap DOWN
   (scroll down, never sideways). */
.lobby-panel.lobby-wide {
  max-width: none;
}
.lobby-panel.lobby-wide .lobby-columns {
  grid-template-columns: minmax(320px, 420px) minmax(0, 1fr);
}
@media (min-width: 1400px) {
  /* Three-up only from the THIRD team (Jordan, 2026-09-02): a two-team multi-team room renders
     the same 50/50 split as the classic two-team lobby; .lobby-teams-many is set by the renderer
     when the room holds >= 3 teams. */
  .lobby-panel.lobby-multiteam .lobby-teams.lobby-teams-many {
    grid-template-columns: repeat(3, minmax(0, 1fr));
  }
}

/* The info column is STICKY at top:0 — the matchmaking side may scroll, the game's identity stays
   put. A nonzero sticky top is forbidden: it pushes the column down whenever the scrollport starts
   below it, which is what broke the title/settings alignment. */
.lobby-panel.lobby-wide .lobby-aside {
  position: sticky;
  top: 0;
}

/* The scroll model (Jordan, 2026-08-29): the matchmaking column is a fixed-height flex stack — the
   head row and Start never move; ONLY the teams container scrolls. It is CONTENT-SIZED, so Start
   always touches the bottom of the teams block and it only shrinks-and-scrolls once it would
   overflow. */
.lobby-panel.lobby-wide .lobby-body {
  /* Fill exactly the overlay's content box: 100vh minus its OWN padding (top clamp + bottom 40px,
     see #lobby-overlay). Subtracting anything less than the real 40px bottom pad leaves the panel a
     few px too tall and the whole overlay scrolls even when nothing overflows. */
  height: calc(100vh - clamp(13px, 3vh, 27px) - 40px);
}
.lobby-panel.lobby-wide .lobby-teams {
  flex: 0 1 auto;
  min-height: 0;
  overflow-y: auto;
  align-content: start;
  padding-right: 4px;
}
/* The bottom fade mask ONLY while the block actually overflows (JS toggles the class) — a
   content-sized block must not fade its own last row. */
.lobby-panel.lobby-wide .lobby-teams.lobby-teams-scrollable {
  -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 52px), transparent);
  mask-image: linear-gradient(to bottom, #000 calc(100% - 52px), transparent);
}
/* The scroll clip amputates outer glows — so inside the teams block the columns wear an INNER glow
   instead (border + tint stay as-is). */
.lobby-panel.lobby-wide .lobby-teams .lobby-team-col {
  box-shadow: none;
}
@supports (color: color-mix(in srgb, red 50%, white)) {
  .lobby-panel.lobby-wide .lobby-teams .lobby-team-col {
    box-shadow: inset 0 0 30px color-mix(in srgb, var(--lobby-team, #232838) 26%, transparent);
  }
}
.lobby-panel.lobby-wide .lobby-teams::-webkit-scrollbar {
  width: 8px;
}
.lobby-panel.lobby-wide .lobby-teams::-webkit-scrollbar-thumb {
  background: rgba(255, 255, 255, 0.14);
  border-radius: 999px;
}
.lobby-panel.lobby-wide .lobby-teams::-webkit-scrollbar-track {
  background: transparent;
}

/* Tablet / mobile (≤999px, matching the base lobby's breakpoint): the two columns STACK into one
   full-width column — the game-selector/info on top, the matchmaking teams below it, dominant and
   full width. This overrides the base ≤999px rule, which (a) caps .lobby-columns to the narrow
   --lobby-main-w and (b) flips .lobby-body ABOVE the aside (order:-1) — for multi-team that reversal
   drops the game selector to the bottom, which is the "columns flipped" bug. It also unwinds the
   desktop fixed-height scroll model (100vh body + sticky aside + inner teams scroller), so the page
   scrolls normally as one document instead of trapping the teams in an inner scrollport. */
@media (max-width: 999px) {
  .lobby-panel.lobby-wide .lobby-columns {
    grid-template-columns: minmax(0, 1fr);
    max-width: none;
    margin: 0;
  }
  .lobby-panel.lobby-wide .lobby-body {
    order: 0; /* undo the base flip: the info column (game selector) stays on top */
    height: auto; /* drop the desktop fixed-height scroll model */
  }
  .lobby-panel.lobby-wide .lobby-aside {
    position: static; /* not sticky once the columns are stacked */
  }
  .lobby-panel.lobby-wide .lobby-teams {
    overflow: visible; /* the page scrolls, not an inner scroller */
  }
  .lobby-panel.lobby-wide .lobby-teams.lobby-teams-scrollable {
    -webkit-mask-image: none;
    mask-image: none;
  }
}

/* The matchmaking column's HEAD: one row, settings chips left, invite right. The invite shrinks
   long before anything wraps (the link middle-ellipsizes), and when a wrap does happen it is the
   SETTINGS that drop to a second line — the invite owns the top row. wrap-reverse does that: the
   overflowing last item (the invite) starts a new flex line, and wrap-reverse renders later lines
   ABOVE earlier ones. */
.lobby-mt-topbar {
  display: flex;
  align-items: stretch;
  justify-content: space-between;
  gap: 12px;
  flex-wrap: wrap-reverse;
}
.lobby-mt-settings {
  flex: none;
  display: flex;
  align-items: stretch;
  gap: 10px;
}
/* The claim-host pill, immediately left of the invite (Jordan, 2026-09-02). The settings chips'
   own 44px skin, so the head row reads as one family; the auto left margin is what parks it
   against the invite rather than the chips — the invite's own `margin-left: auto` then has no
   free space left to take. */
.lobby-claim-host {
  flex: none;
  margin-left: auto;
  height: 44px;
  box-sizing: border-box;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  padding: 0 16px;
  /* Both occupants of this slot ("Claim host" and the host's "Host" badge) share one pinned
     width so the claim -> host swap moves nothing in the head row (Jordan, 2026-09-02). */
  min-width: 138px;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.06);
  border: 1px solid rgba(255, 255, 255, 0.14);
  color: #fff;
  font-family: inherit;
  cursor: pointer;
}

.lobby-claim-host:hover {
  background: rgba(255, 255, 255, 0.12);
  border-color: rgba(255, 255, 255, 0.28);
}

.lobby-claim-host svg {
  width: 15px;
  height: 15px;
  flex: none;
}

/* The same slot when the crown is already yours: a quiet badge, not an offer. Same box, same
   crown, and GOLD where the offer was neutral (Jordan, 2026-09-02: gold border, gold crown,
   white label — between plain and fully gold, never a gold fill). No hover, no pointer. Flat
   and front-on like every other chip. */
.lobby-claim-host-you {
  background: color-mix(in srgb, #e5b71a 7%, transparent);
  border-color: color-mix(in srgb, #e5b71a 62%, transparent);
  color: #ffffff;
  cursor: default;
}

.lobby-claim-host-you svg {
  color: #e5b71a;
}

.lobby-claim-host-you:hover {
  background: color-mix(in srgb, #e5b71a 7%, transparent);
  border-color: color-mix(in srgb, #e5b71a 62%, transparent);
}

.ig-claim-host-label {
  font-size: 11px;
  font-weight: 800;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  line-height: 1;
  white-space: nowrap;
}

/* Between the press and the next state (§5a's claimHostPending): dimmed, never hidden — the head
   row may not change shape because a command is in flight. */
.lobby-claim-host:disabled {
  opacity: 0.5;
  cursor: default;
  background: rgba(255, 255, 255, 0.06);
  border-color: rgba(255, 255, 255, 0.14);
}

/* When the pill stands, ITS auto margin is the row's one — two auto margins would split the free
   space and strand the pill mid-row. The invite then sits flush at the topbar's own 12px gap.
   (Three selectors deep so it outweighs the `margin: 0 0 0 auto` in the topbar block below,
   which this rule precedes in the file.) */
.lobby-mt-topbar .lobby-claim-host + .lobby-share-row {
  margin-left: 0;
}

.lobby-mt-topbar .lobby-share-row {
  margin: 0 0 0 auto;
  padding-top: 0;
  padding-bottom: 0;
  height: 44px;
  box-sizing: border-box;
  align-items: center;
  width: auto;
  flex: 1 1 auto;
  min-width: 230px;
  max-width: 310px /* a third narrower (Jordan, 2026-08-30) */;
}
.lobby-mt-topbar .lobby-share-link {
  min-width: 0;
}
/* Smaller than the pill it sits in: lobby.css's .lobby-copy-btn is height:48px + a min-width sized
   to "Invite link" — both overridden here, and only inside the multi-team topbar. */
.lobby-mt-topbar .lobby-copy-btn {
  padding: 0 12px;
  height: 30px;
  min-width: 0;
  font-size: 11px;
  flex: none;
  display: inline-flex;
  align-items: center;
  gap: 6px;
}
.lobby-mt-topbar .lobby-copy-btn svg {
  width: 12px;
  height: 12px;
}

/* Each setting is ONE self-contained chip — its own fill and border, no card-in-a-card. The
   stepper's −/+ are flat hits; the select is label + value + chevron. This is the slot future
   per-game settings share. */
.lobby-setting {
  height: 44px;
  box-sizing: border-box;
  display: flex;
  align-items: center;
  gap: 9px;
  padding: 7px 14px;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.06);
  border: 1px solid rgba(255, 255, 255, 0.14);
  color: #fff;
}
/* A setting chip that hosts the custom Dropdown (v1.28): the pill draws the border + label, the
   Dropdown trigger sits transparent inside it. */
.lobby-setting-dd {
  cursor: pointer;
}
.lobby-setting-label {
  font-size: 11px;
  font-weight: 800;
  letter-spacing: 0.14em;
  color: var(--lobby-text-dim);
}
.lobby-setting-value {
  font-size: 13px;
  font-weight: 600;
}

/* ---- The lobby skin of the shared Dropdown (web/shell/src/components/Dropdown.tsx) ---- */
.lobby-dd {
  position: relative;
  display: inline-flex;
}
.lobby-dd .ig-select__trigger {
  appearance: none;
  -webkit-appearance: none;
  display: inline-flex;
  align-items: center;
  gap: 7px;
  background: none;
  border: 0;
  color: #fff;
  font: inherit;
  font-size: 13px;
  font-weight: 600;
  padding: 0;
  cursor: pointer;
}
.lobby-dd .ig-select__chevron {
  color: var(--lobby-text-dim);
  transition: transform 0.15s ease;
}
.lobby-dd.is-open .ig-select__chevron {
  transform: rotate(180deg);
}
.lobby-dd .ig-select__menu {
  position: absolute;
  top: calc(100% + 8px);
  left: -14px;
  min-width: 190px;
  max-height: 320px;
  overflow-y: auto;
  z-index: 60;
  padding: 6px;
  border-radius: 14px;
  background: var(--lobby-menu-bg, #14151f);
  border: 1px solid var(--lobby-panel-border);
  box-shadow: 0 18px 46px rgba(0, 0, 0, 0.55);
  outline: none;
  display: flex;
  flex-direction: column;
  gap: 2px;
}
.lobby-dd .ig-select__option {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 14px;
  padding: 8px 11px;
  border-radius: 9px;
  font-size: 13px;
  font-weight: 600;
  color: var(--lobby-text);
  cursor: pointer;
}
.lobby-dd .ig-select__option.is-active {
  background: rgba(255, 255, 255, 0.08);
}
.lobby-dd .ig-select__option.is-selected {
  color: #fff;
}
.lobby-dd .ig-select__option.is-selected .ig-select__option-label::before {
  content: "";
  display: inline-block;
  width: 6px;
  height: 6px;
  margin-right: 8px;
  border-radius: 999px;
  background: var(--lobby-accent);
  vertical-align: middle;
}
.lobby-dd .ig-select__option.is-disabled {
  opacity: 0.4;
  cursor: default;
}
.lobby-dd .ig-select__option-hint {
  font-size: 11px;
  font-weight: 800;
  letter-spacing: 0.04em;
  color: var(--lobby-text-dim);
  white-space: nowrap;
}
/* The auto-pick's "Best" tag reads as an accent badge, not just dim text. */
.lobby-dd-region .ig-select__option-hint {
  color: var(--lobby-accent);
}
.lobby-step {
  width: 26px;
  height: 26px;
  border: 0;
  border-radius: 999px;
  padding: 0;
  background: none;
  color: #fff;
  font-size: 17px;
  line-height: 1;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
}
.lobby-step:hover:not(:disabled) {
  background: rgba(255, 255, 255, 0.14);
}
.lobby-step:disabled {
  opacity: 0.3;
  cursor: default;
}
.lobby-count {
  min-width: 20px;
  text-align: center;
  font-weight: 800;
  font-size: 15px;
  color: #fff;
  font-variant-numeric: tabular-nums;
}

/* Start bar: CENTERED, max-width 600px, flanked by hairline gradient rules; plain flow below the
   scroll area, nothing painted behind it. */
.lobby-mt-actions {
  display: flex;
  align-items: center;
  gap: 18px;
}
.lobby-mt-rule {
  flex: 1;
  height: 1px;
}
.lobby-mt-rule-l {
  background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.22));
}
.lobby-mt-rule-r {
  background: linear-gradient(90deg, rgba(255, 255, 255, 0.22), transparent);
}
.lobby-mt-actions .lobby-cta-big {
  position: static;
  flex: 0 1 600px;
  max-width: 600px;
}

/* The v1.20 ranked results screen (endgame.ranking[]): rank, the team's label in its colour, its
   per-column values, winner styling on rank 1. */
.lobby-results.lobby-results-ranked {
  display: flex;
  align-items: center;
  justify-content: center;
}
.lobby-ranked {
  position: relative;
  z-index: 1;
  width: min(720px, 92vw);
  display: flex;
  flex-direction: column;
  gap: 20px;
}
.lobby-ranked-head {
  font-family: var(--lobby-display);
  font-weight: var(--lobby-display-weight);
  font-size: clamp(22px, 4vw, 40px);
  text-align: center;
  text-transform: uppercase;
  letter-spacing: 0.04em;
}
.lobby-ranked-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 10px;
}
.lobby-ranked-row {
  display: flex;
  align-items: center;
  gap: 16px;
  padding: 14px 20px;
  border-radius: 16px;
  background: rgba(255, 255, 255, 0.05);
  border: 2px solid var(--lobby-panel-border);
}
@supports (color: color-mix(in srgb, red 50%, white)) {
  .lobby-ranked-row {
    background: color-mix(in srgb, var(--lobby-team, #232838) 14%, transparent);
    border-color: color-mix(in srgb, var(--lobby-team, #232838) 72%, #ffffff);
  }
  .lobby-ranked-row-win {
    box-shadow: 0 0 44px color-mix(in srgb, var(--lobby-team, #232838) 40%, transparent);
  }
}
.lobby-ranked-rank {
  min-width: 34px;
  text-align: center;
  font-size: 22px;
  font-weight: 800;
  font-variant-numeric: tabular-nums;
}
.lobby-ranked-name {
  flex: 1;
  min-width: 0;
  font-family: var(--lobby-display);
  font-weight: 900;
  font-size: clamp(16px, 2vw, 22px);
  letter-spacing: 0.02em;
}
.lobby-ranked-cols {
  display: flex;
  gap: 22px;
}
.lobby-ranked-col {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 2px;
}
.lobby-ranked-col-label {
  font-size: 10px;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--lobby-text-dim);
}
.lobby-ranked-col-value {
  font-size: 18px;
  font-weight: 800;
  font-variant-numeric: tabular-nums;
}

/* ---- the v1.23 free-for-all lobby + results ---- */

/* ONE pool instead of two team columns. The column keeps its own skin, its glow and its rows;
   only the track count changes, because a pool is a column and not a new kind of thing. */
.lobby-teams.lobby-teams-ffa {
  grid-template-columns: minmax(0, 1fr);
}

/* The free-for-all results screen: no halves, no divider, no team names — one centred placement
   table over the same nebula backdrop the ranked screen uses. */
.lobby-results.lobby-results-ffa {
  display: flex;
  align-items: center;
  justify-content: center;
}

/* Jordan, 2026-08-31: the screen was a black field, and it is the same screen as the two-team one
   minus the split — so it takes the two-team half's whole treatment, in ONE colour. The screen sets
   --lobby-results-side from the page's second team colour (FfaResults.tsx), which is what the blue
   half of a two-team result already paints with; the layers below are .lobby-results-half-left's,
   with the bloom and the bottom haze re-centred because there is no divider to lean away from and
   no horizontal darkening to run into it. Kept as a modifier ON .lobby-results-half so the plain
   declaration in that rule stays the no-color-mix fallback, exactly as -left/-right do. */
.lobby-results-half-ffa {
  background:
    radial-gradient(55% 60% at 50% 20%, color-mix(in srgb, var(--lobby-half-bloom) 31%, transparent), transparent 70%),
    radial-gradient(70% 30% at 50% 106%, rgba(255, 255, 255, 0.1), transparent 70%),
    linear-gradient(180deg, rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.52)),
    var(--lobby-results-side, #232838);
}

.lobby-ffa {
  position: relative;
  z-index: 1;
  width: min(720px, 92vw);
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: 20px;
}
/* The headline stands where the two halves' score numerals do, so it is dressed as one: the
   results display face, and the numeral's own fills — white falling to a cool violet-white, and the
   winner's gold (.lobby-ffa-head-win, set only when ONE player owns first place; a tie keeps the
   plain fill because nobody won it). The flat `color` before each gradient is the
   no-background-clip fallback, guarded the same way .lobby-results-score is. */
.lobby-ffa-head {
  font-family: var(--lobby-results-display);
  font-style: italic;
  font-weight: 900;
  font-size: clamp(24px, 4.4vw, 52px);
  line-height: 1.05;
  text-align: center;
  text-transform: uppercase;
  letter-spacing: 0.02em;
  color: rgba(255, 255, 255, 0.94);
  text-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
}

.lobby-ffa-head-win {
  color: #ffe9a8;
  text-shadow: 0 12px 40px rgba(0, 0, 0, 0.45), 0 0 44px rgba(255, 213, 108, 0.4);
}

@supports ((-webkit-background-clip: text) or (background-clip: text)) {
  .lobby-ffa-head {
    background: linear-gradient(180deg, #ffffff 0%, #cfd6ff 100%);
    -webkit-background-clip: text;
    background-clip: text;
    color: transparent;
    text-shadow: none;
    filter: drop-shadow(0 14px 36px rgba(0, 0, 0, 0.6));
  }

  /* background-IMAGE, not the shorthand — see .lobby-results-score-win. */
  .lobby-ffa-head-win {
    background-image: linear-gradient(180deg, #fff6cf 0%, #f3c349 100%);
    color: transparent;
    text-shadow: none;
    filter: drop-shadow(0 0 34px rgba(245, 197, 66, 0.45)) drop-shadow(0 14px 36px rgba(0, 0, 0, 0.6));
  }
}
/* The table is the two-team screen's, so it inherits every row, cell and entrance rule. It is not
   sitting under a score numeral here, so it drops the strip gap. */
.lobby-results-ffa .lobby-results-table {
  margin-top: 0;
}
/* One and two stat columns. The default row grid is rank + name + THREE numeric tracks (the v1.5
   trio) and `data-cols="4"` narrows them; a free-for-all sender usually carries one column, which
   would otherwise leave two empty tracks on every row. */
.lobby-results-table[data-cols="1"] .lobby-results-row {
  grid-template-columns: clamp(18px, 2vw, 34px) minmax(0, 1fr) clamp(56px, 6vw, 96px);
}
.lobby-results-table[data-cols="2"] .lobby-results-row {
  grid-template-columns: clamp(18px, 2vw, 34px) minmax(0, 1fr) clamp(44px, 4.6vw, 80px) clamp(56px, 6vw, 96px);
}
/* First place. The gold of the two-team screen's winner frame, on the row instead of on a half —
   there is no half to frame. A tie simply lights every row that shares the place. The rank chip and
   the leading number take the same gold the winning numeral wears, so the row reads as won and not
   merely outlined. */
.lobby-results-ffa .lobby-results-row-win {
  border-color: #f0bd3f;
  box-shadow: 0 0 26px rgba(245, 197, 66, 0.4);
}
.lobby-results-ffa .lobby-results-row-win .lobby-results-rank {
  background: rgba(240, 189, 63, 0.22);
  color: #ffe9a8;
}
.lobby-results-ffa .lobby-results-row-win .lobby-results-total {
  color: #ffe9a8;
}

/* The v1.11 bench inside the fixed-height wide body: it may not push Start off the panel, so past
   its own few rows it shrinks and scrolls like the teams block above it. */
.lobby-panel.lobby-wide .lobby-bench {
  flex: 0 1 auto;
  min-height: 0;
  overflow-y: auto;
}


/* ---- the launch curtain (Jordan, 2026-08-30) ------------------------------------------------

   Pressing Start fades the screen to a full-viewport loading state, the astroball.io loader's
   family: near-black field, a twinkling star field with three shooting stars crossing it, a soft
   accent halo, the game's title floating, LOADING pulsing under it. Fixed and viewport-sized, so
   it escapes the panel and covers rail + panel + frame alike. The frozen lobby stays mounted
   underneath — this layer is presentation only. No Saturn glyph: that one is astroball's, and this
   curtain is every game's, so the game's own title carries the identity and --lobby-accent the
   colour. */
@keyframes lobbyLaunchFade {
  from { opacity: 0; }
  to { opacity: 1; }
}

@keyframes lobbyLaunchFloat {
  0%, 100% { transform: translateY(0); }
  50% { transform: translateY(-10px); }
}

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

@keyframes lobbyLaunchTwinkle {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.3; }
}

/* Down and to the left, off the edge, then wait out the rest of the cycle — the three streaks
   differ only in where they start and how long they wait. */
@keyframes lobbyLaunchShoot {
  0% { transform: translate(0, 0) rotate(-35deg); opacity: 0; }
  5% { opacity: 1; }
  22% { transform: translate(-540px, 378px) rotate(-35deg); opacity: 0; }
  100% { transform: translate(-540px, 378px) rotate(-35deg); opacity: 0; }
}

.lobby-launch-screen {
  position: fixed;
  inset: 0;
  z-index: 80;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 26px;
  background: #030309;
  overflow: hidden;
  animation: lobbyLaunchFade 0.5s ease both;
}

/* The star field: ten fixed dots painted as background layers rather than ten nodes. Two of them
   take the game's accent so bubble-blitz's sky is blue and astroball's is red. */
.lobby-launch-screen::before {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  background-image:
    radial-gradient(2px 2px at 12% 22%, #fff 50%, transparent 51%),
    radial-gradient(1.5px 1.5px at 35% 12%, color-mix(in srgb, var(--lobby-accent, #4e84ff) 45%, #ffffff) 50%, transparent 51%),
    radial-gradient(2px 2px at 58% 28%, #fff 50%, transparent 51%),
    radial-gradient(1.5px 1.5px at 79% 16%, #ffd7d7 50%, transparent 51%),
    radial-gradient(2px 2px at 92% 34%, #fff 50%, transparent 51%),
    radial-gradient(1.5px 1.5px at 22% 48%, #fff 50%, transparent 51%),
    radial-gradient(1.5px 1.5px at 8% 76%, color-mix(in srgb, var(--lobby-accent, #4e84ff) 45%, #ffffff) 50%, transparent 51%),
    radial-gradient(2px 2px at 44% 82%, #fff 50%, transparent 51%),
    radial-gradient(1.5px 1.5px at 68% 64%, #fff 50%, transparent 51%),
    radial-gradient(2px 2px at 86% 78%, #cfd8ff 50%, transparent 51%);
  animation: lobbyLaunchTwinkle 4s ease-in-out infinite;
}

.lobby-launch-shot {
  position: absolute;
  width: 180px;
  height: 2px;
  border-radius: 2px;
  background: linear-gradient(90deg, #fff, transparent);
  opacity: 0;
  pointer-events: none;
  animation: lobbyLaunchShoot 7s linear infinite;
}

.lobby-launch-shot-1 {
  top: 10%;
  right: 8%;
}

.lobby-launch-shot-2 {
  top: 22%;
  right: 42%;
  width: 140px;
  animation-duration: 9s;
  animation-delay: 2.4s;
}

.lobby-launch-shot-3 {
  top: 52%;
  right: 16%;
  width: 160px;
  animation-duration: 11s;
  animation-delay: 4.8s;
}

.lobby-launch-halo {
  position: absolute;
  width: min(560px, 90vw);
  height: min(560px, 90vw);
  border-radius: 50%;
  background: radial-gradient(circle, color-mix(in srgb, var(--lobby-accent, #4e84ff) 18%, transparent) 0%, transparent 70%);
  pointer-events: none;
}

.lobby-launch-title {
  position: relative;
  font-family: var(--lobby-cta-font);
  font-weight: 900;
  font-size: clamp(34px, 6vw, 64px);
  color: #fff;
  letter-spacing: 0.02em;
  text-align: center;
  animation: lobbyLaunchFloat 3.2s ease-in-out infinite;
  filter: drop-shadow(0 0 32px color-mix(in srgb, var(--lobby-accent, #4e84ff) 55%, transparent));
}

.lobby-launch-loading {
  position: relative;
  font-size: 13px;
  font-weight: 500;
  color: var(--lobby-text-dim);
  letter-spacing: 0.5em;
  text-indent: 0.5em;
  animation: lobbyLaunchPulse 1.6s ease-in-out infinite;
}

/* The 3-2-1 numerals (Jordan, 2026-09-03), in the LOADING pulse's slot — the old platform's
   "GAME STARTS IN <n>" giant digit, on this curtain's tokens. One node per beep (keyed in
   LaunchCountdown.tsx), so the pop restarts with each number. min-height pins the slot at the
   digit's height whether it holds a numeral or the LOADING line, so the title never shifts. */
.lobby-launch-count {
  position: relative;
  min-height: 1em;
  font-family: var(--lobby-cta-font);
  font-weight: 900;
  font-size: clamp(90px, 16vw, 180px);
  line-height: 1;
  color: #fff;
  text-align: center;
  animation: lobbyLaunchCountPop 1s ease-out both;
  filter: drop-shadow(0 0 40px color-mix(in srgb, var(--lobby-accent, #4e84ff) 60%, transparent));
}

@keyframes lobbyLaunchCountPop {
  0% { transform: scale(1.6); opacity: 0; }
  18% { transform: scale(1); opacity: 1; }
  85% { transform: scale(1); opacity: 1; }
  100% { transform: scale(0.94); opacity: 0.85; }
}

/* Everything that moves stops; the curtain still fades in (opacity, not motion) and still reads
   as itself — title, halo, LOADING and a still star field. The streaks rest at opacity 0, which
   is where their keyframes start, so reduced motion simply has no shooting stars. */
@media (prefers-reduced-motion: reduce) {
  .lobby-launch-screen::before,
  .lobby-launch-shot,
  .lobby-launch-title,
  .lobby-launch-loading,
  .lobby-launch-count {
    animation: none;
  }
}

/* ---------- the engine's frame wears no focus ring (Jordan, 2026-08-30) ----------

   The match starts, the engine takes the screen, and the browser draws a selection border around
   the thing that just took focus — a bright rectangle around the game, which is chrome nobody asked
   for. Focus itself STAYS (Space and the arrow keys have to reach the game): only the ring and the
   drag-selection go.

   Two elements, because there are two page shapes for the same engine: the inline <canvas> on a
   game page, and the same-origin <iframe> that wears the same id on the lobby page — which is a
   REPLACED element, so it gets a UA border of its own as well. This lives in lobby.css rather than
   in the shell's stylesheet because lobby.css is loaded, unhashed, by every page that can show an
   engine (both game pages and the lobby's own document, and therefore inside the frame too), and it
   ships through the sync/overlay pipeline instead of a shell rebuild.

   Both selectors match nothing on a page without an engine (the mock harness, the picker). */
#unity-canvas,
#unity-container {
  outline: none;
  -webkit-tap-highlight-color: transparent;
}

#unity-canvas:focus,
#unity-canvas:focus-visible,
#unity-container:focus,
#unity-container:focus-visible {
  outline: none;
}

/* An <iframe>'s UA style is `border: 2px inset`, which reads as a frame around the match on the
   lobby page. Harmless on the game pages, where #unity-container is a <div> with no border. */
#unity-container {
  border: 0;
  -webkit-user-select: none;
  user-select: none;
}

/* ---- the join preview (2026-09-02): the OG platform's JoinRoomPreview on this lobby's tokens ----
   A full-viewport veil over EVERYTHING — the overlay (20), the host veil (50), the rail (55), the
   dialog veil (70) — because its whole job is being the one thing a zero-click arrival can press.
   Opaque like the original (banner + 0.9 black wash), never backdrop-filter: it can stand over a
   running canvas (the rejoin case) for as long as the player leaves it, which is exactly the
   persistent-blur case the ban on .lobby-round-btn is about. */
.lobby-join-veil {
  position: fixed;
  inset: 0;
  z-index: 80;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
  box-sizing: border-box;
  background: #0b0c14;
  animation: lobby-join-veil-in 0.2s ease;
}

@keyframes lobby-join-veil-in {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

@media (prefers-reduced-motion: reduce) {
  .lobby-join-veil {
    animation: none;
  }
}

/* The game's art where the OG modal had the community banner, under the same two washes: the
   accent radial from the top-left and the heavy black. BLURRED (Jordan, 2026-09-02) — but with
   filter on this static layer, never backdrop-filter on the veil: a filtered image renders once,
   where a backdrop blur over the live canvas (the rejoin case) would cost a compositor readback
   every frame the veil stands. The negative inset hides the blur's darkened edge fringe. */
.lobby-join-art {
  position: absolute;
  inset: -32px;
  background-size: cover;
  background-position: center;
  filter: blur(14px);
}

.lobby-join-wash {
  position: absolute;
  inset: 0;
  background:
    radial-gradient(at left top, color-mix(in srgb, var(--lobby-accent) 22%, transparent) 17%, color-mix(in srgb, var(--lobby-accent) 11%, transparent) 85%),
    linear-gradient(0deg, rgba(0, 0, 0, 0.9), rgba(0, 0, 0, 0.9));
}

.lobby-join-panel {
  position: relative;
  width: min(592px, 100%);
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 30px;
  padding: 50px;
  border-radius: 20px;
  border: 1px solid color-mix(in srgb, var(--lobby-accent) 20%, transparent);
  background: #12131e;
  color: var(--lobby-text);
  box-shadow: 0 24px 60px rgba(0, 0, 0, 0.55);
  text-align: center;
}

.lobby-join-panel * {
  box-sizing: border-box;
}

/* The identity chip: the game where the OG modal had the community. */
.lobby-join-chip {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 10px;
  border-radius: 14px;
  background: rgba(0, 0, 0, 0.3);
}

.lobby-join-chip-art {
  width: 24px;
  height: 24px;
  border-radius: 8px;
  object-fit: cover;
}

.lobby-join-chip-name {
  font-size: 18px;
  font-weight: 500;
  line-height: 1;
}

/* The room's name, the panel's headline — the slug IS the name on this platform. */
.lobby-join-room {
  max-width: 100%;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
  font-family: var(--lobby-display);
  font-weight: var(--lobby-display-weight);
  font-size: clamp(26px, 4vw, 40px);
  line-height: 1.1;
}

/* "<n> playing <game>" with the OG emphasis split: the facts green, the connective dim. */
.lobby-join-line {
  font-size: 18px;
  font-weight: 500;
  line-height: 1.5;
}

.lobby-join-line-strong {
  color: #5efc81;
}

.lobby-join-line-dim {
  color: var(--lobby-text-dim);
}

/* Who is inside: 32px plates, the OG rounded squares carrying this lobby's initials. */
.lobby-join-avatars {
  display: flex;
  align-items: center;
  justify-content: center;
  flex-wrap: wrap;
  gap: 10px;
}

.lobby-join-avatar {
  /* Positioned only so the phase-2 portrait below can lie over the initial. Nothing else in the
     plate is positioned, so this changes no pixel on a plate without one. */
  position: relative;
  width: 32px;
  height: 32px;
  border-radius: 10px;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 14px;
  font-weight: 800;
  color: #ffffff;
  background: rgba(255, 255, 255, 0.14);
}

/* Characters phase 2: the portrait over the plate, in the PLATE's shape - this strip's avatar is
   the OG rounded square, not the roster's circle, so the image inherits the radius instead of
   naming one. A plate whose image fails loses the image (joinPreview.ts removes it) and is the
   initials plate again. */
.lobby-join-portrait {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border-radius: inherit;
  object-fit: cover;
}

/* The empty room's ghost strip, the OG opacity ramp in from both edges. */
.lobby-join-avatar-ghost {
  background: rgba(255, 255, 255, 0.05);
}

.lobby-join-avatars .lobby-join-avatar-ghost:nth-child(2),
.lobby-join-avatars .lobby-join-avatar-ghost:nth-child(4) {
  background: rgba(255, 255, 255, 0.1);
}

.lobby-join-avatars .lobby-join-avatar-ghost:nth-child(3) {
  background: rgba(255, 255, 255, 0.15);
}

/* The one thing this veil exists to be pressed: the house CTA, sized like the OG's lg button.
   It is FOCUSED on build (so a bare Enter joins), and the browser's focus ring on that
   programmatic focus read as a stray white border (Jordan, 2026-09-02) — the CTA's own gradient
   and glow are its affordance, so the outline goes. */
.lobby-join-cta {
  max-width: 320px;
  min-height: 56px;
  font-size: 14px;
  outline: none;
}

.lobby-join-back {
  font-size: 12px;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--lobby-text-dim);
  text-decoration: none;
  cursor: pointer;
}

.lobby-join-back:hover {
  color: var(--lobby-text);
  text-decoration: underline;
}
