/* Design tokens. These are the CLASSIC theme's values and the fallback for
   everything; the other three themes redefine them in themes.css, selected by
   [data-theme] on <html>. Anything drawn on canvas instead of by the browser
   goes through Klotzo.SKIN — see theme.js. Keep the two in step. */
:root {
  --bg: #070811;
  --bg-image: radial-gradient(1200px 800px at 50% -10%, #16193080, transparent);
  --panel: #121527;
  --panel-2: #1b1f38;
  --panel-3: #232842;      /* button hover */
  --well: #0c0f1e;         /* inputs, slots, meter troughs */
  --board: #0b0d17;        /* playfield backdrop behind the canvas */
  --text: #e6e8f0;
  --muted: #8b93b0;
  --accent: #22d3ee;
  --accent-2: #a855f7;
  --accent-3: #ec4899;     /* third stop in the clear-popup gradient */
  --on-accent: #0a0c16;    /* text on an accent-filled surface */
  --danger: #ef4444;
  --danger-2: #7f1d1d;
  --warn: #f59e0b;
  --gold: #facc15;
  --ok: #22c55e;
  --border: rgba(255, 255, 255, 0.08);
  --border-hover: rgba(255, 255, 255, 0.16);
  --scrim: rgba(7, 8, 17, 0.82);       /* over a board: pause, countdown, game over */
  --scrim-modal: rgba(4, 5, 12, 0.72); /* full-screen behind a dialog */
  --shadow-panel: 0 24px 60px rgba(0, 0, 0, 0.45);

  /* Type. One face. Press Start 2P sets everything on the site — the wordmark,
     buttons, the clock, scores, the board labels, and now the prose too: the
     tagline, chat, the mode notes, the legal pages. It used to be split, with
     the arcade face for the things the eye lands on before it reads and the
     system face for the things it reads left to right, on the argument that a
     bitmap face with one weight, no italic and an em-wide advance per character
     is a sign and not a paragraph. That is still true of the face; the call is
     that a game whose menu, HUD and high-score table are all one arcade face
     should not switch to Segoe UI for the sentence under the logo. The site
     reads as one machine now, and the prose is short everywhere it appears.
     --font-system is the fallback stack and nothing else. Every token below
     ends in it so that a character outside the two woff2 subsets — a name on
     the board, mostly — lands on a real text face instead of on nothing.
     Themes used to each bring their own display face (Arial Black, Avenir Next
     Condensed) and no longer do: it is the site's face now, not a per-theme
     choice. What a theme still sets is --display-transform, and its own colour
     and outline treatment on top. */
  --font-system: "Segoe UI", system-ui, -apple-system, Roboto, Helvetica, Arial, sans-serif;
  --font-body: "Press Start 2P UI", var(--font-system);
  --font-display: "Press Start 2P UI", var(--font-system);
  --display-transform: none;
  /* 400 and 0 are not taste, they are the only correct values for this face.
     It ships one weight, so any other number asks the browser to fake a bold by
     smearing each glyph sideways, which on a bitmap face reads as a rendering
     fault; and every glyph already carries an eighth of an em of empty space on
     its right, which is the tracking these rules used to add by hand. */
  --display-weight: 400;
  --display-spacing: 0;
  /* The leaderboard keeps the unadjusted face — its sizes are tuned in whole
     pixels against it, and size-adjust would shrink them by a third. Named
     rather than written inline because three rules need it and they must never
     drift apart; the fallback goes straight to --font-system rather than
     through --font-body, because --font-body is the adjusted face and a name
     with one glyph the subsets don't carry would render that glyph at 65% of
     the others. */
  --font-score: "Press Start 2P", var(--font-system);

  /* Per-player identity colour. Themes that distinguish the two boards set
     these; app.js does not need to know about it. */
  --side-you: var(--accent);
  --side-them: var(--accent-2);
  --side: var(--side-you);

  /* Inks for the pixel sprite in each board heading, all drawn in app.js.
     Tokens rather than literals inside the SVG because the icons are inline in
     the page and a theme has to be able to repaint them: the same gold that
     glows on a near-black card disappears on arcade's pale one. --icon-ink is
     the sprite outline, and it is meant to be invisible on the dark themes —
     there it reads as the shadow around the shape, which is what an outline in
     a sprite always was. Only the shapes app.js actually draws have a token
     here; an ink nothing keys to is one more colour to keep in step across
     three palettes for no picture. */
  --icon-ink: rgba(0, 0, 0, 0.55);
  --icon-gold: #f0b429;
  --icon-gold-hi: #ffe08a;
  --icon-steel: #b8c6da;
  --icon-grip: #a2662f;
  --icon-medal: #d9a441;

  /* The high-score ramp: one colour per place, 1st to 10th. Written out rather
     than pointed at the semantic tokens above, because this is a designed
     sequence and not a set of meanings — nothing here says "danger" at 7th —
     and because the semantic tokens collide when you line them up (in the night
     palette --ok and --accent are the same green, which would have put 1st and
     10th in the same colour). Every theme redefines all ten in themes.css: the
     surface underneath changes, so the ramp has to. */
  --rank-1: #4ade80;
  --rank-2: #e6e8f0;
  --rank-3: #b8c0d4;
  --rank-4: #facc15;
  --rank-5: #f7b32b;
  --rank-6: #fb923c;
  --rank-7: #f87171;
  --rank-8: #c084fc;
  --rank-9: #f472b6;
  --rank-10: #22d3ee;
}

/* ---------- the face ----------
   Press Start 2P (SIL OFL 1.1, see fonts/OFL.txt), the only web font on the
   site. It started out confined to the menu leaderboard, which is imitating an
   arcade high-score table and cannot do that in Segoe UI; then it took the
   display text, and now it is simply the site's face — every token above ends
   in it — with the leaderboard the component that got there first.
   Two subsets, latin and latin-ext, 8KB together. latin-ext was already not
   optional when only the board used it, because the board is full of names
   people typed; now that the prose is on the same face, every accented word in
   the German legal pages needs it too, and without it those characters fall
   back to the system face in the middle of a word.
   Now that all of the page's text waits on this file, the swap below is visible
   in a way it was not when it dressed the wordmark: on a cold connection the
   whole page paints in --font-system first and then reflows. That is still the
   right trade — 8KB arriving late beats a blank page — but it is the reason to
   leave both subsets small and to keep this the only web font.
   Self-hosted because it has to be: deploy/nginx/stackzilla.conf sends
   `font-src 'self'`, so a Google Fonts URL would be blocked, not slow.
   NOTE ON CACHING: nginx gives woff2 `expires 1y`, and nothing stamps a url()
   inside a stylesheet — web/tools/stamp.js only rewrites src=/href= in HTML.
   These two files are therefore immutable. Changing the face means adding a
   file under a new name and pointing this block at it; it never means editing
   these bytes in place, because the browsers that already have them will not
   ask again for a year. */
@font-face {
  font-family: "Press Start 2P";
  font-style: normal;
  font-weight: 400;
  /* swap, not block: the board is drawn from a websocket message that arrives
     long after the CSS does, so in practice the font is already there and the
     choice only decides what happens on a cold, slow connection. Late is
     better than invisible. */
  font-display: swap;
  src: url("fonts/press-start-2p-latin.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
    U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
    U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: "Press Start 2P";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("fonts/press-start-2p-latin-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7,
    U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF,
    U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

/* The same two files under a second name, scaled down — this is what
   --font-display points at, and the only thing that separates the two names.
   The face draws its capitals the full height of the em and gives every glyph a
   full em of advance; Segoe UI, which every size in this stylesheet was chosen
   against, uses about 0.7em and 0.5em. Dropped in at the old sizes it overflowed
   buttons and pushed the round clock out of its plate. size-adjust fixes that
   once, here, instead of in sixty font-size declarations — and leaves the
   leaderboard, tuned in whole pixels against the unadjusted face, alone.
   65% puts the capitals a little under the body face's, which is the right
   place: solid pixel strokes read larger than they measure.
   The three metric overrides are the other half of the same job. This font
   declares an ascent of one em and a descent of zero — its baseline is the
   bottom edge of the 8×8 grid it is drawn on — so its natural line box is
   exactly as tall as a capital and two stacked lines touch. Under a label like
   SCORE that put the number into the word. 156/44 opens the box to 1.3× the
   font size, which is about what a text face gives you, with the ink centred in
   it. Both numbers are percentages of the *adjusted* em, so they are already
   scaled by the 65% above.
   Same URLs as above, so the second name costs no second download. */
@font-face {
  font-family: "Press Start 2P UI";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  size-adjust: 65%;
  ascent-override: 156%;
  descent-override: 44%;
  line-gap-override: 0%;
  src: url("fonts/press-start-2p-latin.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
    U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
    U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: "Press Start 2P UI";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  size-adjust: 65%;
  ascent-override: 156%;
  descent-override: 44%;
  line-gap-override: 0%;
  src: url("fonts/press-start-2p-latin-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7,
    U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF,
    U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

* { box-sizing: border-box; }

body {
  margin: 0;
  min-height: 100vh;
  background: var(--bg-image), var(--bg);
  color: var(--text);
  font-family: var(--font-body);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 20px;
}

.hidden { display: none !important; }

h1 {
  /* KLOTZO is ten characters of a face whose every character is the same
     width, so the wordmark's width is font-size × 10 with nothing to give.
     Fixed at 2.6rem it ran off the menu card on a narrow phone. */
  font-size: clamp(1.6rem, 6.8vw, 2.6rem);
  font-family: var(--font-display);
  font-weight: var(--display-weight);
  text-transform: var(--display-transform);
  letter-spacing: var(--display-spacing);
  margin: 0 0 4px;
  background: linear-gradient(90deg, var(--accent), var(--accent-2));
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}

.tagline { color: var(--muted); margin: 0 0 28px; }

/* ---------- Hero: the win condition, shown ----------
   A miniature of the real match HUD — the 2:00 round clock and the five KO
   lamps — so the menu states the rules instead of describing the genre. */
.hero-rules {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 12px;
  margin: 12px 0 0;
  padding: 10px 14px;
  border: 1px solid var(--border);
  border-radius: 12px;
  background: var(--panel-2);
  position: relative;
}
.hr-clock {
  font-family: var(--font-display);
  font-weight: var(--display-weight);
  font-size: 20px;
  letter-spacing: var(--display-spacing);
  font-variant-numeric: tabular-nums;
  color: var(--text);
}
.hr-sep {
  width: 1px;
  align-self: stretch;
  background: var(--border);
}
.hr-lamps { display: flex; gap: 7px; }
.hr-lamps i {
  width: 13px;
  height: 13px;
  border-radius: 50%;
  background: var(--panel-3);
  border: 1px solid var(--border);
  /* Each lamp lights a beat after the one before it, then they all reset —
     five KOs in five seconds, which is the rule the strip exists to teach. */
  animation: hero-lamp 5s linear infinite;
}
.hr-lamps i:nth-child(1) { animation-delay: 0.2s; }
.hr-lamps i:nth-child(2) { animation-delay: 1.0s; }
.hr-lamps i:nth-child(3) { animation-delay: 1.8s; }
.hr-lamps i:nth-child(4) { animation-delay: 2.6s; }
.hr-lamps i:nth-child(5) { animation-delay: 3.4s; }
@keyframes hero-lamp {
  0%, 3%   { background: var(--panel-3); box-shadow: none; }
  6%, 84%  { background: var(--accent-2); box-shadow: 0 0 8px var(--accent-2); }
  88%, 100% { background: var(--panel-3); box-shadow: none; }
}
/* The payoff on the fifth lamp: the strip announces the knockout, which is
   how the match actually ends. */
.hr-ko {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 12px;
  background: var(--panel-2);
  font-family: var(--font-display);
  font-weight: var(--display-weight);
  letter-spacing: var(--display-spacing);
  font-size: 20px;
  color: var(--accent);
  opacity: 0;
  animation: hero-ko 5s linear infinite;
}
@keyframes hero-ko {
  0%, 80%   { opacity: 0; }
  84%, 94%  { opacity: 1; }
  98%, 100% { opacity: 0; }
}
/* Motion here is decoration on top of text that already reads correctly, so
   the honest reduced-motion answer is to stop it, not to substitute a fade. */
@media (prefers-reduced-motion: reduce) {
  .hr-lamps i, .hr-ko { animation: none; }
  .hr-lamps i:nth-child(1), .hr-lamps i:nth-child(2) {
    background: var(--accent-2);
  }
  .hr-ko { opacity: 0; }
}

.hero-sub {
  color: var(--muted);
  font-size: 13px;
  line-height: 1.45;
  margin: 0 0 4px;
}

/* ---------- Menu ---------- */
#menu {
  width: min(460px, 92vw);
  text-align: center;
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 18px;
  padding: 36px 28px;
  box-shadow: var(--shadow-panel);
}

.menu-brand { margin-bottom: 22px; }
.menu-btns { display: flex; flex-direction: column; gap: 12px; }
.menu-play,
.menu-modes { display: flex; flex-direction: column; gap: 12px; }
/* Reserved so a message appearing under the modes doesn't shift the footer,
   and collapsed when there is nothing to say. */
#menu-msg { margin: 0; min-height: 18px; }
#menu-msg:empty { min-height: 0; }

/* ---------- mode tiles ----------
   The other four modes: four keys, one word each, on the same 2-up track the
   join row below them uses, so the whole right-hand half is a single grid of
   six cells and every edge in it lines up. They carried a second line of
   explanation until it turned out that four explanations in a bitmap face at
   11px are harder to get through than the four words they explain, and that
   explanations of unequal length leave the tiles unequal heights. A word
   centred on its own key is read at a glance; the mode's own screen carries the
   detail, which is where somebody who picked it is looking anyway.

   The label is centred rather than set to the left because with the note gone
   a tile is no longer a small card — it is a key, the same object as Join under
   it and Quick match beside it, and it should look like one. */
.mode-grid { display: grid; gap: 10px; }
.mode-tile {
  display: flex;
  align-items: center;
  justify-content: center;
  text-align: center;
  /* Shorter sides than a plain button: two of these sit on one track, and
     "Free-for-all" needs the room more than the padding does. */
  padding: 14px 10px;
  font-size: 0.82rem;
  line-height: 1.15;
  text-wrap: balance;
}

button {
  /* `font: inherit` first to clear the browser's own control face, then the
     display face over it: a button is a label, not prose. */
  font: inherit;
  font-family: var(--font-display);
  font-weight: var(--display-weight);
  letter-spacing: var(--display-spacing);
  color: var(--text);
  background: var(--panel-2);
  border: 1px solid var(--border);
  border-radius: 12px;
  padding: 14px 18px;
  cursor: pointer;
  transition: transform 0.08s ease, background 0.15s ease, border 0.15s ease;
}
button:hover { background: var(--panel-3); border-color: var(--border-hover); }
button:active { transform: translateY(1px); }

button.primary {
  background: linear-gradient(90deg, var(--accent), var(--accent-2));
  color: var(--on-accent);
  border: none;
}
button.primary:hover { filter: brightness(1.07); }

/* The same track as .mode-grid above, so the code field is exactly as wide as
   the tile over it and Join is as wide as the tile over Join. A flex row sized
   them by their content instead, which put a long field and a small button
   under two equal tiles. */
.join-row {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  gap: 10px;
}
.join-row input {
  font: inherit;
  text-transform: uppercase;
  letter-spacing: 2px;
  text-align: center;
  color: var(--text);
  background: var(--well);
  border: 1px solid var(--border);
  border-radius: 12px;
  padding: 0 14px;
}

.divider {
  display: flex;
  align-items: center;
  gap: 10px;
  color: var(--muted);
  font-family: var(--font-display);
  font-weight: var(--display-weight);
  letter-spacing: var(--display-spacing);
  font-size: 0.7rem;
  margin: 8px 0;
}
.divider::before, .divider::after {
  content: "";
  flex: 1;
  height: 1px;
  background: var(--border);
}

/* ---------- Playfield layout ---------- */
.screen { width: 100%; display: flex; flex-direction: column; align-items: center; }
/* The game screen starts at the top of the window rather than being centred in
   it: a playfield is tall, and vertical centring spends the difference on empty
   space above the board. */
body:has(#game:not(.hidden)) { align-items: flex-start; }

.arena {
  display: flex;
  gap: 22px;
  align-items: flex-start;
  justify-content: center;
  flex-wrap: wrap;
}
/* Two boards side by side have to be the same object twice over. Your side
   column has one more line in it than the opponent's mirror, which left the
   shells 18px different and their bottom edges visibly out of step.

   nowrap, because wrapping is the wrong answer here: a window too narrow for
   both rigs used to drop the opponent's board onto its own row below yours,
   which is a layout you can't play a versus match in. fitArena scales the pair
   down instead, so they stay side by side at whatever size the window allows.
   The phone layout sets nowrap for itself already (mobile.css). */
/* Centred, not stretched. Stretch was right while both rigs were the same
   size; now yours is full height and an opponent's is a fraction of it, and
   what those boards should share is a midline. */
.arena.versus { align-items: center; flex-wrap: nowrap; }

.player {
  display: flex;
  gap: 14px;
  align-items: flex-start;
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 16px;
  padding: 16px;
}
.player.dead { opacity: 0.5; filter: grayscale(0.5); }

/* Exactly as wide as the canvas, and no wider. As a stretched flex child of
   .board-col it took the whole column's width instead — so anything else in the
   column (a long score in .under-stats) silently widened it, and .name-tag,
   which fills it, ended up overhanging the board it is supposed to sit on.
   Shrink-to-fit plus align-self keeps the board centred over a wider column.
   The phone layout opts back out — see .board-wrap in mobile.css. */
.board-wrap {
  position: relative;
  align-self: center;
  width: fit-content;
}
canvas.board {
  display: block;
  border-radius: 8px;
  background: var(--board);
  box-shadow: inset 0 0 0 1px var(--border);
}

.side { display: flex; flex-direction: column; gap: 14px; min-width: 96px; }
.side h3 {
  margin: 0 0 6px;
  font-family: var(--font-display);
  font-weight: var(--display-weight);
  font-size: 0.72rem;
  letter-spacing: var(--display-spacing);
  text-transform: uppercase;
  color: var(--muted);
}
.slot {
  background: var(--well);
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 8px;
}
canvas.mini { display: block; width: 100%; height: auto; }

.stat { margin-bottom: 8px; }
.stat .label {
  font-family: var(--font-display);
  font-weight: var(--display-weight);
  font-size: 0.68rem;
  letter-spacing: var(--display-spacing);
  text-transform: uppercase;
  color: var(--muted);
}
.stat .value {
  font-family: var(--font-display);
  font-weight: var(--display-weight);
  letter-spacing: var(--display-spacing);
  font-size: 1.4rem;
  font-variant-numeric: tabular-nums;
}

.name-tag {
  font-family: var(--font-display);
  font-weight: var(--display-weight);
  letter-spacing: var(--display-spacing);
  font-size: 0.85rem;
  /* Above the board overlay (.overlay is inset:0 over the same wrapper) so the
     name stays readable behind WAITING… and the game-over card. */
  position: relative;
  z-index: 8;
  margin-bottom: 8px;
  min-height: 26px;
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 8px;
  /* The board sizes the wrapper; the name must never get a vote. `width: 0`
     keeps it out of the shrink-to-fit calculation — otherwise a 16-character
     name is wider than the canvas and pushes the plate past the board's edge —
     and `min-width: 100%` then fills whatever width the board settled on. */
  width: 0;
  min-width: 100%;
}
/* Which means a long name has to be cut, not allowed to spill. */
.name-tag > span:first-child {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* No badge text means no badge — otherwise an empty pill floats beside the
   name looking like a rendering fault. */
.badge:empty { display: none; }
.badge {
  font-family: var(--font-display);
  font-weight: var(--display-weight);
  letter-spacing: var(--display-spacing);
  font-size: 0.62rem;
  padding: 2px 8px;
  border-radius: 999px;
  background: var(--panel-2);
  color: var(--muted);
}

/* garbage meter */
.garbage-meter {
  width: 10px;
  align-self: stretch;
  border-radius: 6px;
  background: var(--well);
  border: 1px solid var(--border);
  position: relative;
  overflow: hidden;
}
.garbage-meter .fill {
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  background: linear-gradient(180deg, var(--danger), var(--danger-2));
  transition: height 0.12s ease;
}

.status-bar {
  margin-top: 18px;
  min-height: 26px;
  color: var(--muted);
  text-align: center;
}
.status-bar b { color: var(--text); }

.controls-hint {
  margin-top: 8px;
  color: var(--muted);
  font-size: 0.8rem;
  text-align: center;
}
.controls-hint kbd {
  background: var(--panel-2);
  border: 1px solid var(--border);
  border-bottom-width: 2px;
  border-radius: 5px;
  padding: 1px 6px;
  font-family: inherit;
  font-size: 0.78rem;
}

/* ═══════════════════════════════════════════════════════════════════════════
   CONTROL DECK — everything above the boards.

   One object, not several. A menu button alone on the field and a clock alone
   between the rigs read as two things nobody placed on purpose, and in solo
   the row above the board was empty apart from that button. The deck is a
   marquee hung off the top edge of the screen with three bays: the menu key,
   a mode-dependent readout, and the crest.

   `data-mode` on #deck picks the middle bay — app.js sets it when a match
   starts. Everything else about the deck is identical across modes, which is
   the point: the same piece of machine, showing what that mode has to show.
   ═══════════════════════════════════════════════════════════════════════════ */
.topbar {
  width: 100%;
  display: flex;
  justify-content: center;
  /* Hung off the top: body carries 20px of padding, and the deck's own top
     edge is squared off so it reads as continuing past the screen. */
  margin: -20px 0 10px;
  z-index: 20; /* .arena-wrap is positioned, and would otherwise hit-test over it */
}
.deck {
  position: relative;
  display: grid;
  /* 1fr on both ends rather than `auto` so the middle bay is centred on the
     deck itself, not pushed off by whichever end bay is currently wider. */
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  min-height: 52px;
  padding: 9px 10px 10px;
  border: 1px solid var(--border);
  border-top: none;
  border-radius: 0 0 16px 16px;
  background: var(--panel);
}
.deck-bay { display: flex; align-items: center; gap: 10px; padding: 0 12px; }
.deck-bay-start { justify-content: flex-start; }
.deck-bay-end { justify-content: flex-end; }
/* Seams between the bays, the way a moulded panel is actually split up. */
.deck-bay-mid { border-inline: 1px solid var(--border); }

/* Only one of these two is ever mounted; see the mode rules below. */
.deck[data-mode="battle"] .mode-plate { display: none; }
.deck:not([data-mode="battle"]) .scoreboard { display: none; }

/* ---------- menu key ---------- */
/* Labelled, because it is the only way out of a match and an unlabelled
   hamburger on a games page is as often a share sheet or a settings tray. */
.burger {
  display: inline-flex;
  align-items: center;
  gap: 9px;
  height: 36px;
  padding: 0 13px;
  border-radius: 10px;
}
.burger-bars { display: grid; gap: 4px; width: 16px; }
.burger-bars i {
  display: block;
  height: 2px;
  border-radius: 2px;
  background: var(--text);
  transform-origin: left center;
  transition: background 0.15s ease, transform 0.18s cubic-bezier(0.2, 0.9, 0.25, 1);
}
/* The middle bar draws back on hover — the only motion the deck makes when
   nothing is happening, and it points at the one control up here. */
.burger:hover .burger-bars i:nth-child(2) { transform: scaleX(0.55); }
.burger:hover .burger-bars i { background: var(--accent); }
.burger-text {
  font-family: var(--font-display);
  font-size: 0.58rem;
  font-weight: var(--display-weight);
  letter-spacing: var(--display-spacing);
  text-transform: uppercase;
}

/* ---------- mode plate (solo, free-for-all) ---------- */
.mode-plate { display: grid; justify-items: center; gap: 2px; padding: 0 6px; }
.mp-name {
  font-family: var(--font-display);
  font-size: 0.72rem;
  font-weight: var(--display-weight);
  letter-spacing: var(--display-spacing);
  text-transform: uppercase;
  color: var(--text);
}
.mp-note {
  font-family: var(--font-display);
  font-weight: var(--display-weight);
  font-size: 0.52rem;
  letter-spacing: var(--display-spacing);
  text-transform: uppercase;
  color: var(--muted);
}
.mp-note:empty { display: none; }

/* ---------- battle scoreboard ---------- */
/* Five KOs before two minutes is the entire win condition, so it is one
   readout: your score, the clock, theirs. Split across the two rigs it was
   two small rows of pips near the bottom of a busy column. */
.scoreboard {
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  gap: 16px;
}
.sb-side { display: flex; align-items: center; gap: 9px; min-width: 0; }
.sb-you { justify-content: flex-end; }
.sb-opp { justify-content: flex-start; }
.sb-name {
  font-family: var(--font-display);
  font-size: 0.6rem;
  font-weight: var(--display-weight);
  letter-spacing: var(--display-spacing);
  text-transform: uppercase;
  color: var(--muted);
  /* `ch` is exact on this face — every glyph is one advance wide — so this is
     fourteen characters and not an estimate. Long names still truncate. */
  max-width: 14ch;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* ---------- battle round clock ---------- */
.match-clock {
  display: grid;
  justify-items: center;
  gap: 3px;
  padding: 5px 15px 6px;
  border-radius: 10px;
  line-height: 1;
  /* Four fixed-width digits and a colon at --mc-time's size, plus the padding.
     112px was measured against a proportional face and clipped the pixel one. */
  min-width: 128px;
}
.mc-label {
  font-family: var(--font-display);
  font-weight: var(--display-weight);
  font-size: 0.5rem;
  letter-spacing: var(--display-spacing);
  text-transform: uppercase;
  color: var(--muted);
}
.mc-time {
  font-family: var(--font-display);
  font-size: 1.85rem;
  font-weight: var(--display-weight);
  letter-spacing: var(--display-spacing);
  font-variant-numeric: tabular-nums;
  color: var(--text);
}
/* The digits are the exact reading; this is the one you can take in without
   looking away from your own stack. Driven by scaleX, so ticking it costs no
   layout — see renderClock in app.js. */
.mc-drain {
  display: block;
  width: 100%;
  height: 4px;
  border-radius: 999px;
  background: var(--well);
  overflow: hidden;
}
.mc-drain i {
  display: block;
  height: 100%;
  border-radius: inherit;
  background: var(--accent);
  transform-origin: left center;
  transition: transform 0.28s linear, background 0.3s ease;
}
/* The last ten seconds are the whole point of a two-minute round. */
.match-clock.urgent .mc-time { color: var(--danger); animation: mc-tick 1s steps(2) infinite; }
.match-clock.urgent .mc-drain i { background: var(--danger); }
/* …and the deck itself picks it up, so the warning is visible from the edge of
   vision rather than only where you are already looking. */
.deck.urgent { animation: deck-alarm 1s ease-in-out infinite; }
@keyframes mc-tick { 0%, 60% { opacity: 1; } 61%, 100% { opacity: 0.55; } }
@keyframes deck-alarm {
  0%, 100% { box-shadow: 0 0 0 0 transparent; }
  50% { box-shadow: 0 6px 22px -4px color-mix(in srgb, var(--danger) 70%, transparent); }
}

/* ---------- crest and wordmark ---------- */
/* A button, because it is also the way back to the menu — but styled back
   down to plain artwork: it has to read as the deck's decal, not as a second
   control competing with the menu key across the bay. */
.deck-mark {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 2px 4px;
  background: none;
  border: 0;
  border-radius: 10px;
  cursor: pointer;
}
.deck-mark:hover { background: none; }
.deck-mark:active { transform: none; }
/* The whole mark lifts on hover, which is the only hint it needs. */
.deck-mark:hover .deck-wordmark { color: var(--text); }
.deck-mark:hover .deck-crest { transform: scale(1.06); }
.deck-mark:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* Rematch is the only thing up here anybody needs to press twice; when it
   appears it takes the room the wordmark was using. */
.deck-bay-end:has(#btn-rematch:not(.hidden)) .deck-wordmark { display: none; }
.deck-wordmark {
  font-family: var(--font-display);
  font-size: 0.55rem;
  font-weight: var(--display-weight);
  letter-spacing: var(--display-spacing);
  text-transform: uppercase;
  color: var(--muted);
  white-space: nowrap;
}
.deck-crest {
  display: block;
  width: 36px;
  height: 36px;
  transition: transform 0.15s cubic-bezier(0.2, 0.9, 0.25, 1);
}
.deck-crest.pop { animation: crest-pop 0.55s cubic-bezier(0.2, 0.9, 0.25, 1); }
@keyframes crest-pop {
  0% { transform: scale(1) rotate(0); }
  28% { transform: scale(1.3) rotate(-9deg); }
  58% { transform: scale(0.95) rotate(6deg); }
  100% { transform: scale(1) rotate(0); }
}

@media (prefers-reduced-motion: reduce) {
  .deck.urgent,
  .deck-crest.pop,
  .match-clock.urgent .mc-time { animation: none; }
  .burger:hover .burger-bars i:nth-child(2) { transform: none; }
  /* The mark's hover lift goes too; its wordmark still changes colour. */
  .deck-mark:hover .deck-crest { transform: none; }
  .mc-drain i { transition: none; }
}

/* ---------- KO lamps (five to win a Battle round) ---------- */
.ko-pips { display: flex; gap: 5px; }
.ko-pip {
  width: 10px;
  height: 10px;
  flex: none;
  border-radius: 999px;
  background: var(--well);
  border: 1px solid var(--border);
}
.ko-pip.on {
  background: var(--danger);
  border-color: var(--danger);
  box-shadow: 0 0 8px color-mix(in srgb, var(--danger) 65%, transparent);
}
/* A knockout landing is the loudest thing that happens in a round. */
.ko-flash {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  pointer-events: none;
  z-index: 9;
  font-family: var(--font-display);
  font-size: 2.4rem;
  font-weight: var(--display-weight);
  letter-spacing: var(--display-spacing);
  color: var(--danger);
  opacity: 0;
}
.ko-flash.show { animation: ko-hit 0.9s ease-out forwards; }
@keyframes ko-hit {
  0%   { opacity: 0; transform: scale(0.5); }
  18%  { opacity: 1; transform: scale(1.25); }
  60%  { opacity: 1; transform: scale(1.05); }
  100% { opacity: 0; transform: scale(1.05); }
}

/* Room code, now shown inside the pause menu. */
.modal-card .room-code {
  font-family: var(--font-display);
  font-weight: var(--display-weight);
  /* The one place tracking survives: a room code is read out loud, character by
     character, and the gaps are what stop it being read as a word. */
  letter-spacing: 3px;
  color: var(--accent);
  font-size: 1rem;
}
.modal-card .room-code:empty { display: none; }

/* combo / back-to-back indicator */
.combo-indicator {
  position: absolute;
  left: 8px;
  top: 46px;
  display: flex;
  flex-direction: column;
  gap: 6px;
  pointer-events: none;
}
.combo-indicator .c {
  font-family: var(--font-display);
  font-weight: var(--display-weight);
  letter-spacing: var(--display-spacing);
  font-size: 1.3rem;
  background: linear-gradient(90deg, var(--accent), var(--accent-2));
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  filter: drop-shadow(0 0 10px rgba(34, 211, 238, 0.7));
  animation: comboPulse 0.6s ease-in-out infinite alternate;
}
.combo-indicator .b {
  font-family: var(--font-display);
  font-weight: var(--display-weight);
  letter-spacing: var(--display-spacing);
  font-size: 1rem;
  background: linear-gradient(90deg, var(--warn), var(--danger));
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  filter: drop-shadow(0 0 10px rgba(245, 158, 11, 0.75));
}
@keyframes comboPulse {
  from { transform: scale(1); }
  to { transform: scale(1.12); }
}

/* transient clear popup (Quad / Combo / B2B + points) */
.clear-popup {
  position: absolute;
  left: 0;
  right: 0;
  top: 38%;
  text-align: center;
  pointer-events: none;
  opacity: 0;
  padding: 0 6px;
  line-height: 1.05;
}
.clear-popup.show { animation: popup 1.2s ease-out forwards; }
.clear-popup .pl {
  font-family: var(--font-display);
  font-weight: var(--display-weight);
  /* "PERFECT CLEAR · QUAD" is twenty fixed-width characters and wraps over a
     ten-cell board whatever we do here; this keeps it to two lines. */
  font-size: 1.5rem;
  letter-spacing: var(--display-spacing);
  background: linear-gradient(90deg, var(--accent), var(--accent-2), var(--accent-3));
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  filter: drop-shadow(0 0 18px rgba(168, 85, 247, 0.95));
}
.clear-popup .pp {
  font-family: var(--font-display);
  font-weight: var(--display-weight);
  letter-spacing: var(--display-spacing);
  font-size: 1.3rem;
  color: var(--gold);
  text-shadow: 0 0 16px rgba(250, 204, 21, 0.85);
}
@keyframes popup {
  0% { opacity: 0; transform: translateY(26px) scale(0.75); }
  15% { opacity: 1; transform: translateY(0) scale(1.28); }
  30% { transform: translateY(0) scale(1.08); }
  70% { opacity: 1; transform: translateY(0) scale(1.08); }
  100% { opacity: 0; transform: translateY(-32px) scale(1.08); }
}

/* ---------- Debug overlay (toggle with F2) ---------- */
#dbg-flash {
  position: fixed;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  font-weight: 900;
  font-size: 5rem;
  color: #fff;
  text-shadow: 0 0 24px rgba(34, 211, 238, 0.9), 0 0 8px #000;
  pointer-events: none;
  opacity: 0;
  z-index: 9999;
  font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
}
#dbg-flash.hit { animation: dbgFlash 0.5s ease-out; }
@keyframes dbgFlash {
  0% { opacity: 0; transform: translate(-50%, -50%) scale(0.6); }
  20% { opacity: 1; transform: translate(-50%, -50%) scale(1.15); }
  100% { opacity: 0; transform: translate(-50%, -50%) scale(1); }
}
#dbg-panel {
  position: fixed;
  left: 10px;
  top: 10px;
  z-index: 9999;
  background: rgba(7, 8, 17, 0.86);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 10px 12px;
  font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
  font-size: 12px;
  line-height: 1.5;
  color: #cdd3e6;
  pointer-events: none;
  min-width: 220px;
}
#dbg-panel .hl { color: #22d3ee; font-weight: 700; }
#dbg-panel .warn { color: #f59e0b; }
#dbg-panel .bad { color: #ef4444; }
#dbg-panel hr { border: none; border-top: 1px solid var(--border); margin: 6px 0; }
#dbg-panel .row { display: flex; justify-content: space-between; gap: 12px; }
#dbg-panel .log { white-space: pre; color: #8b93b0; }

/* game over overlay */
.overlay {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 6px;
  background: var(--scrim);
  border-radius: 8px;
  text-align: center;
}
.overlay .big {
  font-family: var(--font-display);
  font-weight: var(--display-weight);
  letter-spacing: var(--display-spacing);
  font-size: 1.25rem;
}
.overlay.win .big { color: var(--ok); }
.overlay.lose .big { color: var(--danger); }
.overlay .ov-btn {
  margin-top: 16px;
  pointer-events: auto;
  background: linear-gradient(90deg, var(--accent), var(--accent-2));
  color: var(--on-accent);
  border: none;
}
.overlay .ov-btn:hover { filter: brightness(1.08); }
/* Secondary actions (Menu, Sign in to save) sit under the primary one and
   read as secondary, but stay unmistakably clickable — at game over these are
   the only controls on screen. */
.overlay .ov-btn-extra {
  margin-top: 8px;
  background: var(--panel-2);
  color: var(--text);
  border: 1px solid var(--border);
}

/* ---------- online count + latency chips ---------- */
/* Build stamp under the menu. Deliberately quiet — it exists to answer "am I
   actually running the new code", which is a question you only ask on purpose,
   so it shouldn't compete with anything you look at while playing. */
.build-line {
  margin-top: 18px;
  text-align: center;
  color: var(--muted);
  opacity: 0.55;
  font-size: 11px;
  font-variant-numeric: tabular-nums;
  letter-spacing: 0.04em;
  user-select: text; /* so it can be read back over a screen share or pasted */
}

/* Footer legal links. Quiet, but never hidden: an imprint that takes hunting
   for is not an imprint. */
.legal-links {
  display: flex;
  justify-content: center;
  gap: 16px;
  margin-top: 20px;
  font-size: 12px;
}
.legal-links a { color: var(--muted); text-decoration: none; }
.legal-links a:hover { color: var(--text); text-decoration: underline; }

.netline {
  display: flex;
  gap: 8px;
  justify-content: center;
  flex-wrap: wrap;
  margin-top: 14px;
}
.netline .chip {
  font-size: 0.78rem;
  color: var(--muted);
  background: var(--panel-2);
  border: 1px solid var(--border);
  border-radius: 999px;
  padding: 4px 11px;
  display: inline-flex;
  align-items: center;
  gap: 6px;
  white-space: nowrap;
}
.netline .chip.good { color: var(--ok); }
.netline .chip.warn { color: var(--warn); }
.netline .chip.bad { color: var(--danger); }
.netline .dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--ok);
  box-shadow: 0 0 6px var(--ok);
}

/* ---------- in-match chat ---------- */
.chat {
  position: fixed;
  right: 16px;
  bottom: 16px;
  width: 300px;
  max-width: calc(100vw - 32px);
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 12px;
  box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
  z-index: 50;
  overflow: hidden;
  font-size: 0.85rem;
}
.chat-head {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 9px 12px;
  cursor: pointer;
  user-select: none;
  background: var(--panel-2);
  border-bottom: 1px solid var(--border);
}
.chat-title { font-weight: 600; }
.chat-unread {
  background: var(--accent-2);
  color: var(--on-accent);
  font-weight: 700;
  font-size: 0.72rem;
  border-radius: 999px;
  padding: 1px 7px;
}
.chat-toggle {
  margin-left: auto;
  background: none;
  border: none;
  color: var(--muted);
  font-size: 1rem;
  cursor: pointer;
  padding: 0 2px;
}
.chat.collapsed .chat-body { display: none; }
.chat-body { display: flex; flex-direction: column; }
.chat-log {
  height: 160px;
  overflow-y: auto;
  padding: 10px 12px;
  display: flex;
  flex-direction: column;
  gap: 6px;
}
.chat-msg { display: flex; flex-direction: column; max-width: 85%; }
.chat-msg .who { font-size: 0.66rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; }
.chat-msg .txt {
  background: var(--panel-2);
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 5px 9px;
  word-break: break-word;
  line-height: 1.35;
}
.chat-msg.mine { align-self: flex-end; align-items: flex-end; }
.chat-msg.mine .txt {
  background: linear-gradient(90deg, var(--accent), var(--accent-2));
  color: var(--on-accent);
  border: none;
}
.chat-msg.theirs { align-self: flex-start; }
.chat-msg.theirs .txt { animation: chatIn 0.18s ease; }
@keyframes chatIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
/* Centred and muted rather than italic: see .lb-empty on why there is no
   italic to ask this face for. */
.chat-sys { color: var(--muted); font-size: 0.75rem; text-align: center; }
.chat-emotes {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  padding: 8px 12px;
  border-top: 1px solid var(--border);
}
.chat-emotes .emote {
  background: var(--panel-2);
  border: 1px solid var(--border);
  border-radius: 999px;
  color: var(--text);
  font: inherit;
  font-size: 0.78rem;
  padding: 3px 10px;
  cursor: pointer;
}
.chat-emotes .emote:hover { border-color: var(--accent); color: var(--accent); }
.chat-input-row { display: flex; gap: 6px; padding: 8px 12px 12px; }
.chat-input-row input {
  flex: 1;
  min-width: 0;
  background: var(--well);
  border: 1px solid var(--border);
  border-radius: 8px;
  color: var(--text);
  font: inherit;
  padding: 7px 10px;
}
.chat-input-row input:focus { outline: none; border-color: var(--accent); }
.chat-input-row button {
  background: var(--panel-2);
  border: 1px solid var(--border);
  border-radius: 8px;
  color: var(--text);
  font: inherit;
  font-family: var(--font-display);
  font-weight: var(--display-weight);
  letter-spacing: var(--display-spacing);
  padding: 7px 12px;
  cursor: pointer;
}
.chat-input-row button:hover { border-color: var(--accent); }
@media (max-width: 640px) {
  .chat { right: 8px; left: 8px; bottom: 8px; width: auto; }
  .chat-log { height: 120px; }
}

/* ---------- name input + leaderboard ---------- */
.name-input {
  background: var(--well);
  border: 1px solid var(--border);
  border-radius: 10px;
  color: var(--text);
  font: inherit;
  padding: 11px 14px;
  text-align: center;
  margin-bottom: 4px;
}
.name-input:focus { outline: none; border-color: var(--accent); }

/* Every input on the site. Without this a placeholder is whatever grey the
   browser picked — the one string on the card that isn't in the palette, and on
   a pale theme it lands somewhere around 3:1 on a white well. --muted is the
   colour the rest of the card's secondary text is already using, and opacity:1
   is for Firefox, which dims a placeholder again on top of its colour. */
::placeholder {
  color: var(--muted);
  opacity: 1;
}

/* Signed-out marker under the name box. Quiet — it is a statement of fact, not
   a warning — but it carries a real control, because the answer to "why am I a
   guest?" is one click away and shouldn't be a hunt for the footer button. */
.guest-note {
  color: var(--muted);
  font-size: 12px;
  margin: 0 0 6px;
}
.linkish {
  background: none;
  border: none;
  color: var(--accent);
  cursor: pointer;
  font: inherit;
  padding: 0;
  text-decoration: underline;
}
.linkish:hover { filter: brightness(1.15); }
/* The invite link shown as text, for a browser that gave us no clipboard to
   put it on. Wraps and breaks: it is there to be selected, not clicked. */
.invite-plain {
  background: none;
  border: none;
  color: var(--text);
  font: inherit;
  padding: 0;
  cursor: text;
  user-select: all;
  word-break: break-all;
}
/* Confirmation that a scheduled match's invite page now names this room, on
   the line that already says which room it is. Quiet on purpose: it is news
   for exactly one person, the host, and only for the few seconds before
   somebody walks through the door it describes. */
.invite-note { color: var(--muted); }
.leaderboard {
  display: flex;
  gap: 14px;
  margin-top: 18px;
  flex-wrap: wrap;
  justify-content: center;
}
/* Titles the three boards as one thing. The columns already carry headings, but
   those name the boards; this names the screen, which is what the ramp below it
   is pretending to be. Full-basis so it takes a wrap line of its own inside the
   flex row, which is why it needs no separate wrapper element in index.html —
   app.js appends it as the first child of #leaderboard and the existing
   show/hide on that element keeps working untouched. */
.lb-headline {
  flex-basis: 100%;
  margin: 0 0 10px;
  text-align: center;
  font-family: var(--font-score);
  font-weight: 400;
  /* px, not rem, and this holds for the whole board below. A bitmap face is
     drawn on a grid, and a size that lands between grid steps is a row of
     letters with one column of pixels smeared across two. Fixed sizes keep the
     grid; they also stop a browser's larger default text size from doubling the
     width of a face that is already twice as wide as the body font. */
  font-size: 15px;
  text-transform: uppercase;
  /* 0.16em was tuned for a proportional face that needed opening up. This one
     carries its own spacing inside every glyph. */
  letter-spacing: 0.04em;
  background: linear-gradient(90deg, var(--accent), var(--accent-2));
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}
.lb-col {
  flex: 1;
  min-width: 130px;
  background: var(--panel-2);
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 10px 12px;
  /* #menu centres everything, which left the names floating in the middle of
     the space between the rank and the score and made every row start in a
     different place. A ranked list is read down the names, so they line up.
     The scores stay hard right: they are numbers set in tabular figures, and
     the whole point of those is that the digits line up in a column. */
  text-align: left;
}
.lb-col h4 {
  display: flex;
  align-items: center;
  gap: 6px;
  margin: 0 0 10px;
  font-family: var(--font-score);
  font-size: 9px;
  color: var(--muted);
  text-transform: uppercase;
  letter-spacing: 0.04em;
  /* The face has one weight. Asking for 700 gets a synthesised bold, which on a
     bitmap face means every stem smeared one pixel to the right. */
  font-weight: 400;
}
.lb-col ol { margin: 0; padding: 0; list-style: none; counter-reset: rank; }
.lb-col li {
  display: flex;
  /* Baseline, not centre: the ordinal is set smaller than the row it labels,
     and centring made every "1ST" ride high above its own name. */
  align-items: baseline;
  justify-content: space-between;
  gap: 8px;
  padding: 3px 0;
  font-family: var(--font-score);
  font-size: 11px;
  /* The face's own line box is tight, and at 11px the rows closed up into a
     block. This is the leading the old proportional rows had, restated for a
     size that is now fixed. */
  line-height: 1.55;
  counter-increment: rank;
  color: var(--rank, var(--text));
}
/* Written-out ordinals rather than "1.", because they cost the same width and
   read as a placing instead of a list index. Only ten rows ever arrive (see
   db.topSolo(10)), so the three irregular suffixes can simply be spelled out
   and everything else takes TH. */
.lb-col li::before {
  content: counter(rank) "TH";
  color: inherit;
  opacity: 0.72;
  /* Monospace, so the gutter can be stated in characters: "10TH" is four of
     them and the widest ordinal there is (the server sends ten rows —
     db.topSolo(10)), plus a quarter for the gap. */
  width: 4.25em;
  flex: none;
}
.lb-col li:nth-child(1)::before { content: "1ST"; }
.lb-col li:nth-child(2)::before { content: "2ND"; }
.lb-col li:nth-child(3)::before { content: "3RD"; }

/* The rank ramp: ten rows, ten colours, running green through gold and red to
   cyan down the table. It encodes nothing — 7th place is not "more red" than
   6th in any meaningful sense — and that is fine, because its job is to make
   ten rows read as one object instead of ten separate lines. Borrowed wholesale
   from the C64 RoboCop high-score screen, which is still the best-looking one
   ever shipped. The colours themselves are --rank-1..10 at the top of this file
   so each theme can restate the whole ramp against its own card. */
.lb-col li:nth-child(1) { --rank: var(--rank-1); }
.lb-col li:nth-child(2) { --rank: var(--rank-2); }
.lb-col li:nth-child(3) { --rank: var(--rank-3); }
.lb-col li:nth-child(4) { --rank: var(--rank-4); }
.lb-col li:nth-child(5) { --rank: var(--rank-5); }
.lb-col li:nth-child(6) { --rank: var(--rank-6); }
.lb-col li:nth-child(7) { --rank: var(--rank-7); }
.lb-col li:nth-child(8) { --rank: var(--rank-8); }
.lb-col li:nth-child(9) { --rank: var(--rank-9); }
.lb-col li:nth-child(10) { --rank: var(--rank-10); }
/* Your own records are a list, not a ranking — there is no 1st or 2nd about
   "most lines sent", so the ordinal and the ramp are both dropped for this
   column only, and its values keep the plain accent colour. */
.lb-col ol.lb-plain li::before { content: none; }
.lb-col ol.lb-plain li { --rank: var(--text); }
.lb-col ol.lb-plain .lb-val { color: var(--accent); }
/* And its labels stay on the body face, where the two ranked boards put names.
   "Most lines in a run" is a sentence, not a name — nineteen characters of a
   face twice the width of the body one, in the only column whose left-hand side
   is prose. Set in pixels it truncated to "Most lines in a…", which is a
   worse-looking version of a label that used to fit. The numbers beside it keep
   the scoreboard face; they are what the column is for. */
.lb-col ol.lb-plain .lb-name {
  font-family: var(--font-body);
  font-size: 0.82rem;
}
.lb-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lb-val {
  color: inherit;
  /* Kept for the fallback face. The scoreboard face is monospace and lines its
     digits up by construction, but a name with a glyph outside the two subsets
     drops that row onto the body font, and there the property still does work. */
  font-variant-numeric: tabular-nums;
  font-weight: 400;
  text-align: right;
  /* "28W · 12L" is one value, and a narrow column was breaking it across two
     lines and making that row twice as tall as its neighbours. The name is the
     part that gives way here — it has an ellipsis for exactly this. */
  white-space: nowrap;
}
/* The leading zeros an arcade panel pads its scores with. Dimmed hard, because
   they are shape and not value: they exist so every score in the column is the
   same width and the digits sit in a block, and a reader who takes them for
   part of the number has been misled by decoration. */
.lb-pad { opacity: 0.28; }
/* 18px for a nine-pixel grid: two device pixels per drawn pixel on an ordinary
   screen and four on a retina one, so the edges stay edges. Any size that is
   not a whole multiple of 9 puts a seam through the middle of a row of pixels,
   which is the one thing pixel art cannot survive. Larger than the 9px word
   beside it on purpose — a marquee is an icon with a label under it, not a
   label with a bullet in front. */
.lb-icon {
  width: 18px;
  height: 18px;
  flex: none;
  display: block;
}

/* Not in the scoreboard face: this is a sentence, not a score, so it takes the
   size-adjusted body face rather than the whole-pixel one the rows are tuned
   in. It used to be italic as well, to set it apart from a real row; the face
   ships no italic, and now that the body face is that same face the browser
   would answer with a synthesised slant — pixels sheared off their own grid,
   which reads as a rendering fault. The muted colour and the smaller size
   already say this is not a score. */
.lb-empty {
  color: var(--muted);
  font-family: var(--font-body);
  font-size: 0.8rem;
}

/* Three boards side by side need a card wider than a 460px one. Below 900px the
   columns go full width and stack instead, because the alternative is two
   columns of about 130px, and after the ordinal and the score that leaves the
   name — the only part of the row anyone is actually looking for — around eight
   characters of a sixteen-character limit. Taller, but it says who is winning.
   The 899px boundary is not a guess about devices: it is the other side of the
   `@media (min-width: 900px)` block below, which is where #menu stops being
   min(460px, 92vw) and becomes wide enough to hold three boards. Move one and
   the other has to move with it. */
@media (max-width: 899px) {
  .lb-col { min-width: 100%; }
}

/* ---------- level card + level-up banner ---------- */
/* Sits above the three boards and is deliberately one wide row rather than a
   fourth column: the level is a single number about you, and columns are for
   lists that rank. Width matched to .leaderboard's own content box so the card
   and the boards share an edge instead of nearly sharing one. */
.level-card {
  display: flex;
  align-items: center;
  gap: 14px;
  /* No width or centring of its own. Both places it appears — the menu footer
     and the account panel — are flex columns that hand it a row to fill, and a
     max-width here only made it refuse part of one. */
  width: 100%;
  margin: 0;
  background: var(--panel-2);
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 10px 14px;
  text-align: left;
}
.lvl-badge {
  display: flex;
  flex-direction: column;
  align-items: center;
  line-height: 1;
  flex: none;
  min-width: 62px;
}
.lvl-word {
  font-size: 0.58rem;
  text-transform: uppercase;
  color: var(--muted);
  font-family: var(--font-display);
  font-weight: var(--display-weight);
  letter-spacing: var(--display-spacing);
}
.lvl-num {
  font-family: var(--font-display);
  font-weight: var(--display-weight);
  letter-spacing: var(--display-spacing);
  font-size: 1.5rem;
  color: var(--accent);
  /* Tabular figures so the card does not reflow every time the number ticks
     over — 1 and 8 are different widths in the body face, and a bar that
     shifts sideways on level-up reads as a glitch. */
  font-variant-numeric: tabular-nums;
  margin-top: 2px;
}
/* The padding is what puts air over the bar. Without it the body is a shorter
   box than the badge beside it, so centring the two rides the track up against
   the card's top edge while the note underneath holds the bottom — the bar ends
   up nearer the lid than the floor. Roughly the note's own height, which is
   what sits under the track on the other side. */
.lvl-body { flex: 1; min-width: 0; padding-top: 10px; }
.lvl-track {
  height: 8px;
  border-radius: 999px;
  background: var(--bg);
  border: 1px solid var(--border);
  overflow: hidden;
}
.lvl-fill {
  height: 100%;
  background: linear-gradient(90deg, var(--accent), var(--accent-2));
  border-radius: 999px;
  /* The bar animates to its new width, which is the only motion that says a
     match just paid. On a level-up it fills, snaps back to near-empty and
     grows again — that is the curve being honest about what happened. */
  transition: width 0.45s ease-out;
}
.lvl-note {
  margin-top: 5px;
  font-size: 0.76rem;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}
/* 999 has no next level to be a fraction of, so the bar stops being a
   measurement and becomes a trophy. */
.level-card.maxed .lvl-fill { background: linear-gradient(90deg, var(--warn), var(--accent)); }
.level-card.maxed .lvl-num { color: var(--warn); }

/* z-index above .modal (200): the XP is paid when a match ends, which is
   precisely when the results card owns the screen. */
.lvl-pop {
  position: fixed;
  left: 50%;
  top: 18%;
  transform: translate(-50%, -14px) scale(0.92);
  z-index: 300;
  pointer-events: none;
  text-align: center;
  padding: 14px 28px;
  border-radius: 14px;
  background: var(--panel);
  border: 1px solid var(--accent);
  box-shadow: 0 0 34px rgba(34, 211, 238, 0.35), 0 12px 40px rgba(0, 0, 0, 0.5);
  opacity: 0;
  transition: opacity 0.25s ease-out, transform 0.25s ease-out;
}
.lvl-pop.show { opacity: 1; transform: translate(-50%, 0) scale(1); }
.lvl-pop-word {
  font-size: 0.66rem;
  text-transform: uppercase;
  color: var(--muted);
  font-family: var(--font-display);
  font-weight: var(--display-weight);
  letter-spacing: var(--display-spacing);
}
.lvl-pop-num {
  font-family: var(--font-display);
  font-weight: var(--display-weight);
  letter-spacing: var(--display-spacing);
  font-size: 2.4rem;
  line-height: 1.05;
  color: var(--accent);
  font-variant-numeric: tabular-nums;
  text-shadow: 0 0 18px rgba(34, 211, 238, 0.55);
}
.lvl-pop-sub { font-size: 0.8rem; color: var(--muted); }
@media (prefers-reduced-motion: reduce) {
  .lvl-fill { transition: none; }
  .lvl-pop { transition: opacity 0.25s ease-out; transform: translate(-50%, 0) scale(1); }
  .lvl-pop.show { transform: translate(-50%, 0) scale(1); }
}

/* ---------- specials (Mario-Kart-style power-ups) ---------- */
.specials { max-width: 520px; margin: 12px auto 0; }
.spec-bar {
  display: flex;
  align-items: center;
  gap: 10px;
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 12px;
  padding: 8px 12px;
}
.spec-meter {
  flex: 1;
  height: 10px;
  background: var(--well);
  border-radius: 999px;
  overflow: hidden;
  border: 1px solid var(--border);
}
.spec-meter-fill {
  height: 100%;
  width: 0%;
  background: linear-gradient(90deg, var(--accent), var(--accent-2));
  transition: width 0.15s linear;
}
.spec-slots { display: flex; gap: 6px; }
.spec-slot {
  position: relative;
  width: 40px;
  height: 40px;
  border-radius: 10px;
  border: 1px solid var(--border);
  background: var(--panel-2);
  color: var(--text);
  cursor: default;
  display: flex;
  align-items: center;
  justify-content: center;
}
.spec-slot .si { font-size: 1.15rem; opacity: 0.35; }
.spec-slot .sk {
  position: absolute;
  bottom: 1px;
  right: 4px;
  font-size: 0.6rem;
  color: var(--muted);
}
.spec-slot.ready {
  cursor: pointer;
  border-color: var(--accent);
  background: linear-gradient(160deg, var(--panel-2), rgba(34, 211, 238, 0.14));
  animation: specPop 0.2s ease;
}
.spec-slot.ready .si { opacity: 1; }
.spec-slot.ready:hover { border-color: var(--accent-2); }
@keyframes specPop { from { transform: scale(0.8); } to { transform: scale(1); } }
.spec-toast {
  text-align: center;
  font-weight: 700;
  font-size: 0.9rem;
  color: var(--accent);
  height: 20px;
  opacity: 0;
  transform: translateY(4px);
  transition: opacity 0.15s, transform 0.15s;
}
.spec-toast.show { opacity: 1; transform: none; }

/* effect overlays applied to a player's own board */
.board-wrap.fx-fog::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 6;
  pointer-events: none;
  background: radial-gradient(120% 80% at 50% 40%, rgba(180, 200, 230, 0.28), rgba(120, 140, 170, 0.55));
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
  border-radius: inherit;
}
.board-wrap.fx-glue::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 5;
  pointer-events: none;
  background: rgba(234, 179, 8, 0.16);
  border-radius: inherit;
}
.board-wrap.fx-flip .board { transform: scaleX(-1); }
.board-wrap.fx-shield {
  box-shadow: 0 0 0 2px var(--accent), 0 0 26px rgba(34, 211, 238, 0.6);
  border-radius: 8px;
}

/* ===================================================================
   KLOTZO shell: mascot, countdown, pause menu, shared modals.
   The .modal / .modal-card pair is the house dialog style — feature
   modules (settings, account, ads) build their dialogs from it so
   everything stays visually consistent.
   =================================================================== */

#mascot {
  display: block;
  margin: 0 auto 4px;
  image-rendering: auto;
  filter: drop-shadow(0 6px 18px rgba(34, 211, 238, 0.25));
  animation: mascot-bob 3.2s ease-in-out infinite;
}
@keyframes mascot-bob {
  0%, 100% { transform: translateY(0) rotate(-1deg); }
  50%      { transform: translateY(-6px) rotate(1deg); }
}

.menu-footer {
  display: flex;
  flex-direction: column;
  /* 18px, not the 8px the rest of this screen uses between stacked things: the
     keys above carry the arcade theme's 4px block shadow, which is painted
     outside their box and so eats the top of any gap measured from it. The
     card needs its clear air under the shadow's lower edge, not under the
     button's. */
  gap: 18px;
  margin-top: 14px;
}
.menu-footer-row { display: flex; gap: 8px; }
.menu-footer-row button { flex: 1; padding: 10px 12px; font-size: 14px; }

/* Account button doubles as the signed-in indicator: an unlit dot next to
   "Sign in", a lit one next to your name. The name can be long, so it
   truncates rather than wrapping the footer onto two lines. */
.acct-btn {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 7px;
  min-width: 0;
}
.acct-dot {
  flex: 0 0 auto;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--panel-3);
  border: 1px solid var(--border);
}
.acct-btn.signed-in { color: var(--text); }
.acct-btn.signed-in .acct-dot {
  background: var(--ok);
  border-color: transparent;
  box-shadow: 0 0 6px var(--ok);
}
#acct-label {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
button.ghost {
  background: transparent;
  color: var(--muted);
}
button.ghost:hover { background: var(--panel-2); color: var(--text); }

/* The menu key's own rules live with the control deck it is seated in. */

/* ---------------------------------------------------- countdown (3-2-1-GO) */
.arena-wrap { position: relative; }

.countdown {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  pointer-events: none;
  z-index: 30;
  background: color-mix(in srgb, var(--scrim) 55%, transparent);
  backdrop-filter: blur(2px);
  border-radius: 16px;
}
.countdown span {
  font-family: var(--font-display);
  font-size: clamp(56px, 15vw, 132px);
  font-weight: var(--display-weight);
  letter-spacing: var(--display-spacing);
  color: var(--text);
  text-shadow: 0 0 30px rgba(34, 211, 238, 0.7), 0 6px 24px rgba(0, 0, 0, 0.6);
}
.countdown span.go {
  background: linear-gradient(90deg, var(--accent), var(--ok));
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}
.countdown span.tick { animation: countdown-pop 0.65s ease-out both; }
@keyframes countdown-pop {
  0%   { transform: scale(2.1); opacity: 0; }
  25%  { transform: scale(1);   opacity: 1; }
  75%  { transform: scale(1);   opacity: 1; }
  100% { transform: scale(0.82); opacity: 0.15; }
}

/* -------------------------------------------------------- shared modals */
.modal {
  position: fixed;
  inset: 0;
  z-index: 200;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 20px;
  background: var(--scrim-modal);
  backdrop-filter: blur(4px);
  animation: modal-fade 0.16s ease-out;
}
@keyframes modal-fade { from { opacity: 0; } to { opacity: 1; } }

.modal-card {
  width: min(440px, 100%);
  max-height: 88vh;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: 24px;
  border-radius: 18px;
  background: var(--panel);
  border: 1px solid var(--border);
  box-shadow: var(--shadow-panel);
  animation: modal-rise 0.2s cubic-bezier(0.2, 0.9, 0.3, 1.2);
}
@keyframes modal-rise {
  from { transform: translateY(14px) scale(0.97); opacity: 0; }
  to   { transform: none; opacity: 1; }
}
/* The card is a scrolling flex column with a max-height. Once its content is
   taller than that, flex-shrink squashes every child — a three-line paragraph
   gets a two-line box and its text spills out below, so whatever follows sits
   almost on top of it. The card scrolls; nothing in it should be compressed. */
.modal-card > * { flex-shrink: 0; }

.modal-card h2 {
  margin: 0 0 2px;
  font-family: var(--font-display);
  font-weight: var(--display-weight);
  font-size: 17px;
  letter-spacing: var(--display-spacing);
}
.modal-card .row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}

/* ------------------------------------------------------- dialog sliders
   Every range input in a dialog — the volumes, the key-repeat delay, the
   price picker — drawn as one control instead of the browser's own. The
   native widget only lets `accent-color` reach the thumb and the filled
   part; its track stays near-black, which reads as a hole punched in a
   cream Arcade card.
   Styling it ourselves costs the filled part, because that is the browser's
   to draw — so the fill is a gradient stop at `--sz-fill`, which the owning
   module updates from the value (see paintRange in settings.js). Two of
   these sliders have no number beside them, so the fill is the only thing
   that says where the value is. It lives here rather than in a module's
   injected CSS because the price picker appears in dialogs that never load
   the settings module. */
.modal-card input[type="range"] {
  -webkit-appearance: none;
  appearance: none;
  height: 20px;
  background: transparent;
  cursor: pointer;
}
.modal-card input[type="range"]::-webkit-slider-runnable-track {
  height: 8px;
  border-radius: 999px;
  border: 1px solid var(--border);
  background: linear-gradient(
    90deg,
    var(--accent) var(--sz-fill, 50%),
    var(--well) var(--sz-fill, 50%)
  );
}
.modal-card input[type="range"]::-moz-range-track {
  height: 8px;
  border-radius: 999px;
  border: 1px solid var(--border);
  background: linear-gradient(
    90deg,
    var(--accent) var(--sz-fill, 50%),
    var(--well) var(--sz-fill, 50%)
  );
}
/* Webkit lays the thumb out from the top of the track's CONTENT box, not from
   the middle of its border box, so centring it costs the border as well as
   half the height difference:

       margin-top = track/2 - border - thumb/2 = 4 - 1 - 8 = -5px

   with every term a border-box height, since `* { box-sizing: border-box }`
   above makes them all one. The old -4px left the thumb sitting one pixel
   below the channel, which at the Arcade theme's heavier border was two and
   plainly visible. Firefox centres its own thumb and wants none of this. */
.modal-card input[type="range"]::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: 16px;
  height: 16px;
  margin-top: -5px;
  border-radius: 50%;
  border: 1px solid var(--border-hover);
  background: var(--text);
}
.modal-card input[type="range"]::-moz-range-thumb {
  width: 16px;
  height: 16px;
  border-radius: 50%;
  border: 1px solid var(--border-hover);
  background: var(--text);
}
.modal-card input[type="range"]:disabled { opacity: 0.5; cursor: default; }
.modal-card label { color: var(--muted); font-size: 14px; }

.pause-budget {
  color: var(--muted);
  font-size: 14px;
  margin-bottom: 6px;
}
.pause-budget.low { color: var(--danger); font-weight: 700; }

/* ===================================================================
   Specials in the side column. The panel used to be a wide horizontal
   bar under the arena; it now lives at the bottom of your own player
   column, so the bar becomes a vertical stack and the toast floats over
   the arena instead of wrapping inside a ~96px column.
   =================================================================== */
.side .specials {
  margin: 0;
  max-width: none;
  width: 100%;
}
.side .spec-bar {
  flex-direction: column;
  align-items: stretch;
  gap: 9px;
  padding: 10px;
}
/* A charge meter, not a progress bar — thin reads as a gauge and stops it
   competing with the slots underneath. */
.side .spec-meter { flex: none; width: 100%; height: 5px; }
.side .spec-slots { justify-content: center; gap: 8px; }
.side .spec-slot { width: 34px; height: 34px; }
.side .spec-slot .si { font-size: 1rem; }

/* Float the toast over the arena — centred, not inside the column. */
.side .spec-toast {
  position: fixed;
  left: 50%;
  top: 84px;
  transform: translateX(-50%) translateY(4px);
  z-index: 40;
  height: auto;
  padding: 6px 14px;
  border-radius: 999px;
  background: var(--panel);
  border: 1px solid var(--border);
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
  white-space: nowrap;
  pointer-events: none;
}
.side .spec-toast.show { transform: translateX(-50%); }

/* ---- versus: board column + stats under the board -------------------- */
.board-col { display: flex; flex-direction: column; gap: 10px; }

/* Score/Lines sit under the board in versus (Level is dropped there — it's
   identical for both players and only cost space). */
.under-stats {
  display: flex;
  justify-content: space-around;
  gap: 10px;
}
.under-stats .stat { margin: 0; text-align: center; }
.under-stats .value { font-size: 1.15rem; }

/* The opponent's specials are a read-only mirror: no pointer affordances. */
.spec-mirror .spec-slot { cursor: default; }
.spec-mirror .spec-slot.ready:hover { border-color: var(--accent); }

/* Display-name input flagged by the local or server-side name rules. */
.name-input.invalid {
  border-color: var(--danger);
  box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.18);
}

/* 3-player free-for-all: three boards share the row. */
.arena.trio { gap: 10px; }
.arena.trio .side { min-width: 78px; }

/* ===================================================================
   CPU difficulty picker. One row per level, hardest last, with the
   strength shown as pips so the ladder reads at a glance instead of
   having to be inferred from the adjectives.
   =================================================================== */
.cpu-list { display: flex; flex-direction: column; gap: 8px; margin: 0; }
/* One line: the name, and the pips out at the right margin. The sentence that
   used to sit under each name is gone — five of them stacked up were the
   longest thing in the dialog, and they explained a ladder the pips draw. */
.cpu-item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  text-align: left;
  padding: 9px 12px;
}
.cpu-name { font-size: 0.85rem; }
.cpu-pips { flex: none; display: flex; gap: 3px; }
.cpu-pips i {
  width: 7px;
  height: 16px;
  border-radius: 2px;
  background: var(--border);
}
.cpu-pips i.on { background: var(--accent, #06b6d4); }
/* The level you played last, so a rematch is one obvious click. */
.cpu-item.last { border-color: var(--accent, #06b6d4); }
.cpu-item.last .cpu-name::after {
  content: " · last played";
  font-size: 0.62rem;
  color: var(--muted);
}

/* ── Battle setup: the map picker that stands in front of every match ──────
   Twelve tiles (an empty field, random, and the ten maps), each a miniature of
   the well itself: ten columns wide, the art sitting on the floor, because the
   picture is the whole reason to choose one and a name alone tells you nothing
   about what you are about to play. */
/* Two of these, over the maps and over the difficulties. They name the question
   and stop; the extra margin on top is what groups each one with the thing
   underneath it rather than with the block above. */
.bs-head {
  margin: 4px 0 0;
  font-family: var(--font-display);
  font-weight: var(--display-weight);
  letter-spacing: var(--display-spacing);
  font-size: 0.62rem;
  text-transform: uppercase;
  color: var(--muted);
}
.bs-maps {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 8px;
  margin: 0;
}
/* The tile is the picture. A card behind it would be a second panel stacked on
   the well, which in the light theme reads as a white slab inside a blue one —
   so the button itself carries no background of its own and the well art is
   what you see and click. */
.bs-map {
  display: flex;
  flex-direction: column;
  gap: 5px;
  padding: 0;
  background: none;
  border: 0;
  cursor: pointer;
  /* No `font: inherit` here: it would undo the display face the house button
     rule sets and leave the only body-face labels in the dialog. */
  color: var(--text);
}
.bs-map:hover .bs-map-art { border-color: var(--muted); }
.bs-map.on .bs-map-art {
  border-color: var(--accent, #06b6d4);
  box-shadow: 0 0 0 1px var(--accent, #06b6d4);
}
.bs-map.on .bs-map-name { color: var(--accent, #06b6d4); }
/* The box is ten cells wide and --map-rows tall, and its HEIGHT follows from
   that rather than being picked: the columns are fluid, so a cell is a tenth of
   whatever width the tile settled on, and a fixed height would cut the picture
   off the moment ten of those did not fit the number guessed here. --map-rows
   is the tallest map in the set, set by app.js, and every map is padded up to
   it with empty rows so they all sit on the floor at one scale — a five-row map
   and a six-row one line up the way they will in the well. */
.bs-map-art {
  display: grid;
  grid-template-columns: repeat(10, 1fr);
  grid-template-rows: repeat(var(--map-rows, 6), 1fr);
  gap: 1px;
  aspect-ratio: 10 / var(--map-rows, 6);
  padding: 3px;
  border-radius: 8px;
  background: var(--well, #0b0d17);
  /* The border is the only edge the tile has now: on a light theme the well is
     white on a near-white modal, and without it the picture would float. */
  border: 1px solid var(--border);
  overflow: hidden;
}
.bs-map-art i { display: block; border-radius: 1px; }
/* The two that are not pictures say so in words, in the same box. */
.bs-map-art.bs-map-word {
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 15px;
  font-weight: var(--display-weight);
  color: var(--muted);
}
.bs-map-name {
  font-size: 0.62rem;
  font-weight: var(--display-weight);
  text-align: center;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
@media (max-width: 520px) {
  .bs-maps { grid-template-columns: repeat(3, 1fr); }
}

/* ═══════════════════════════════════════════════════════════════════════════
   DESKTOP LOBBY — from 900px up.

   Everything above this line describes one narrow column, which is the right
   answer on a phone and was the only answer anywhere: a laptop got the phone
   column with a thousand pixels of grass either side of it and a menu roughly
   three screens tall, so the leaderboard (the only proof anyone else plays
   here) sat below the fold and the five buttons read as a list rather than a
   choice.

   The card gets the width instead. The identity becomes a band across the top,
   and the two halves of the menu sit side by side: the funnel most people came
   for on the left, everything else on the right. Nothing moves in the DOM, so
   the phone layout below 900px is untouched.
   ═══════════════════════════════════════════════════════════════════════════ */
@media (min-width: 900px) {
  #menu {
    width: min(920px, 94vw);
    padding: 30px 34px 26px;
    position: relative; /* the online/latency chips are placed against it */
  }

  /* Up into the corner the brand band leaves empty, rather than orphaned in a
     lane of its own between the footer and the leaderboard. It is live status
     about the room you are about to walk into, so it belongs beside the sign
     over the door. */
  #online {
    position: absolute;
    top: 26px;
    right: 30px;
    margin: 0;
    justify-content: flex-end;
  }

  /* ---------- brand band ---------- */
  /* The mascot beside the wordmark rather than stacked over it. Stacked, the
     identity alone was 380px of a 900px window and the primary control started
     below the fold. */
  .menu-brand {
    display: grid;
    grid-template-columns: auto 1fr;
    align-items: center;
    gap: 22px;
    text-align: left;
    margin-bottom: 24px;
  }
  #mascot { margin: 0; }
  .brand-text .tagline { margin-bottom: 0; }
  /* Night Match marks the wordmark with a short rule under it (themes.css,
     h1::after). It is `margin: 10px auto`, which centred it under the h1's full
     line box - fine in a centred column, but in the left-aligned band it
     floated off on its own halfway across the card. Written against #menu so it
     outranks the theme rule, which loads after this file. */
  #menu .brand-text h1::after { margin-left: 0; margin-right: auto; }
  /* 20px, not the 10px it takes on a phone. This strip and the level card in
     the opposite corner sit on one line (see .menu-footer below), so the only
     way to open air between the card and the row of keys above it without
     shoving those keys up into the latency chip is to walk the whole line
     down. Both move together; the 20px is carried in the corner block's top
     offset. */
  .hero-rules { justify-content: flex-start; margin-top: 20px; }
  /* .hr-ko is inset:0 over the strip and announces the fifth knockout, so the
     strip has to be exactly as wide as its own contents or the word lands in
     the middle of a bar stretched across the column. */
  .hero-rules { width: fit-content; }

  /* ---------- the two halves ---------- */
  .menu-btns {
    display: grid;
    grid-template-columns: 1fr 1fr;
    /* stretch, not start: the funnel on the left is the taller half and always
       will be — a paragraph, a field, a key and a footnote against six keys —
       so the modes take their height from it and the two halves finish on the
       same line. With `start` the right column stopped wherever four one-word
       keys happened to stop, which after the notes came off was well short of
       the left, leaving a notch out of the bottom right of the card. */
    align-items: stretch;
    gap: 18px 30px;
  }
  /* The grid absorbs the difference rather than the join row, which has a field
     in it and should stay the height of a field. Equal rows, so both ranks of
     tiles are the same key. */
  .menu-modes .mode-grid { flex: 1; grid-auto-rows: 1fr; }
  /* The funnel is a column of things you read and one thing you press, so it
     is set to a reading width and centred in its half. Stretched to the full
     565px the half gives it, "Enter your name" sat alone in the middle of a
     box wide enough for a sentence. */
  .menu-play > * { width: min(420px, 100%); margin-inline: auto; }
  .menu-modes { text-align: left; }
  /* A column header rather than a rule across the middle of the card: side by
     side, "or" belongs at the top of the alternative, not between two things
     that are not stacked. */
  .menu-modes .divider { margin: 0 0 2px; }
  .menu-modes .divider::before { content: none; }

  /* The message line belongs to the whole card, not to a column. */
  #menu-msg { grid-column: 1 / -1; }

  /* Settings and the account button are chrome, not part of the funnel: they
     go up into the same empty corner as the status chips, stacked under them,
     rather than spanning the bottom of the card where a row of two full-width
     keys read as the last step of the menu. Placed against #menu, which is the
     positioned ancestor — .menu-btns is a grid but is not positioned. */
  /* The top offset is calculated, not chosen: the level card underneath has to
     land on the same line as the .hero-rules strip across the card, and that
     strip's position is set by two rem-sized things above it — the wordmark and
     the tagline. A plain `top: 62px` held only at a 16px root; at 17px the
     brand band grew and the corner block stayed put, and the two cards were 6px
     out of line, at 18px 11px. So the offset carries the same rem in it:

         30px      #menu's own padding-top
       + 3.25rem   the h1 line box (2.6rem at the display face's 1.25)
       + 4px       the h1's margin-bottom
       + 1.3125rem the tagline's line box (1rem at 1.3125)
       + 20px      .hero-rules' margin-top
       - 45px      the row of keys, which sits above the card
       - 18px      the footer's own gap

     which collapses to the two terms below. The keys ride down with it, and
     they are aligned to nothing on the left, so nothing else has to move. */
  .menu-footer {
    position: absolute;
    top: calc(4.5625rem - 9px);
    /* 4px further out than the chips above, so the block shadow's outer edge
       lands on their right edge rather than overhanging it. */
    right: 34px;
    margin: 0;
    /* Stretch, so the level card underneath comes out exactly as wide as the
       row of keys above it and the corner reads as one block. The footer has no
       width of its own out here — it shrink-wraps the row, and the card then
       takes whatever that settled on. */
    align-items: stretch;
  }
  /* Was on .menu-footer, which is a column now: pushing the keys to the right
     is the ROW's job, and asking the column for it would only shove both
     children to the bottom. */
  .menu-footer-row { justify-content: flex-end; }
  .menu-footer-row button { flex: 0 0 auto; padding: 9px 18px; }

  /* Sits under both columns, where a full-width row of three boards reads as
     one board instead of three cards crowded into a phone column. */
  .leaderboard { margin-top: 20px; gap: 16px; }
  .lb-col { padding: 12px 14px; }
  /* px to match the base rule — see .lb-col li, where the reasoning is. DOWN a
     step, which looks backwards for the widest layout and is not: this is the
     only layout that puts the three boards side by side, so a column here is
     265px against the 404px it gets stacked on a phone. The narrow screen is
     the roomy one. Two pixels buys about five more characters of name before
     the ellipsis, which is the whole argument. */
  .lb-col li { font-size: 10px; padding: 4px 0; }

}

/* Two across as soon as two fit, one below that. The note under each name is
   what makes a tile worth more than a button, so it must never be squeezed to
   the point of wrapping every second word. */
.mode-grid { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); }

/* ═══════════════════════════════════════════════════════════════════════════
   FULL-HEIGHT PLAY — the playfield takes every pixel the chrome doesn't.

   The well is the thing you look at for two minutes straight, and the furniture
   around it used to cost it about 300px on a 1080p screen — six rows of
   playfield spent on a deck, a status line, latency chips and a key hint that
   are all glanceable at half the size.

   So nothing here is an overlay. Everything keeps its place in the layout and
   simply gets trimmed: the deck loses a third of its height, the rows below it
   lose their generous margins, and .arena-wrap takes the whole remainder. That
   is what boardRoom() in app.js measures — arenaWrap's top edge and
   arenaFoot() — so trimming a row here hands the board the difference with no
   number in the JS to keep in step.

   Scoped to `body.playing` so the menu is untouched, and to the desktop
   breakpoint because mobile.css already drives the canvas from the viewport on
   phones and would only be fighting this. PHONE_MAX_W in app.js is the same
   760. ═══════════════════════════════════════════════════════════════════ */
@media (min-width: 761px) {
  body.playing {
    padding: 0;
    /* The board is sized to the window, so there is never anything to scroll
       to — and a stray scroll mid-match would only move the field. */
    overflow: hidden;
  }
  body.playing #game {
    height: 100vh;
    height: 100dvh;   /* mobile browsers: the bar-free height, where supported */
  }

  /* min-height: 0 is load-bearing, not defensive: a flex item's default
     min-height is auto, so without it the arena refuses to shrink below its
     content and pushes the board back off the bottom of the screen — the exact
     thing this whole block exists to stop. */
  body.playing .arena-wrap {
    flex: 1 1 auto;
    min-height: 0;
    width: 100%;
    display: flex;
    align-items: center;
    justify-content: center;
  }
  /* Centred until it doesn't fit, then hard against the top. Plain centring
     splits an overflow between both edges, so a rig one row too tall hung half
     of itself above the wrap and under the deck — and fitArena's scale, which
     is anchored to the top edge, then took the whole difference out of the
     bottom instead. The board rode high and a dead strip sat underneath it.
     Behind @supports because a browser without `safe` drops the whole
     declaration, and the fallback for that is the old centring, not stretch. */
  @supports (align-items: safe center) {
    body.playing .arena-wrap { align-items: safe center; }
  }

  /* Flush to the top edge. The -20px was reaching through the body padding
     this block just removed, which would have hung the deck off the top of the
     window rather than off the top of the page. */
  body.playing .topbar { margin: 0 0 6px; }
  body.playing .deck {
    min-height: 40px;
    padding: 4px 10px 5px;
  }

  /* The rows under the board. Their margins were sized for a page you read,
     not a match you play. */
  body.playing .status-bar {
    margin-top: 6px;
    min-height: 18px;
    font-size: 0.85rem;
  }
  body.playing #net-stats { margin-top: 4px; }
  body.playing .controls-hint {
    margin-top: 4px;
    font-size: 0.72rem;
  }
  body.playing .controls-hint kbd { padding: 0 4px; font-size: 0.7rem; }

  /* The shell stops being a card. Its padding, border and background were
     drawing a frame around a board that now runs nearly the full height of the
     screen, and a frame around the whole screen is just a border. */
  body.playing .player {
    padding: 0;
    gap: 10px;
    background: none;
    border-color: transparent;
  }
  /* Still above the board, never on it — but tight to it. */
  body.playing .name-tag {
    min-height: 20px;
    margin-bottom: 4px;
  }
  body.playing .under-stats .value { font-size: 0.95rem; }
  body.playing .board-col { gap: 6px; }

  /* The column beside the board is shorter than the board and always will be:
     two pods and three stats against twenty rows. On a 900px screen the
     difference is about 90px and on a tall window it is several hundred, and
     all of it used to pile up under the last stat as a hole in the bottom
     corner of the shell. Centred, the same
     leftover is split above and below and reads as the column's margins.
     `stretch` is what gives it a height to centre in — .player tops its
     children out otherwise, so the column was only ever as tall as its own
     content and had no room to place anything in. */
  body.playing .side {
    align-self: stretch;
    justify-content: center;
  }
}

/* ---------- versus: your board is the subject, theirs is a glance ----------
   Both versus modes lay out the same way, which is the point — a 2-player
   match and a 3-player free-for-all are the same game with a different number
   of opponents, and they should not need two mental models. Your rig is at
   full size in the middle; every opponent is the same rig at OPP_SCALE (see
   app.js, which sizes their canvas — this file only handles placement).

   In the free-for-all that means one opponent either side of you, done with
   flex `order` rather than by appending in a different sequence: the seats
   arrive from the server in their own order and the DOM keeps it, so that
   order stays the thing app.js reasons about. */
.arena.versus .player.mine { order: 0; }
.arena.versus .player[data-seat="left"] { order: -1; }
.arena.versus .player[data-seat="right"] { order: 1; }
/* Theirs is a smaller object in every respect, not just a smaller canvas. */
.arena.versus .player:not(.mine) .name-tag { font-size: 0.78rem; }
.arena.versus .player:not(.mine) .under-stats .value { font-size: 0.95rem; }

/* ---------- the gamepad cursor (uinav.js) ----------
   Where the pad is pointing. This exists as its own class rather than as
   :focus-visible because a focus() called from a poll loop is not a keyboard
   interaction and browsers decline to paint the ring for it — see the long
   version in uinav.js.

   It pulses, slowly. A static ring on an unfamiliar card reads as decoration;
   one that breathes reads as a cursor, which is the whole job. That also rules
   out !important on the outline, since an !important declaration beats a CSS
   animation and the colour would sit still — the two fields below that strip
   their own outline on :focus are beaten on specificity instead. */
.sz-nav-cursor {
  outline: 3px solid var(--accent);
  outline-offset: 3px;
  /* scrollIntoView({block:"nearest"}) parks the ring flush against the edge
     of a scrolling card otherwise, with the outline half cut off. */
  scroll-margin: 20px;
  animation: sz-nav-pulse 1.2s ease-in-out infinite;
}
/* .name-input and the chat field both set outline:none on :focus, and confirm
   on a text field focuses it — so the cursor has to outrank that. */
input.sz-nav-cursor:focus,
textarea.sz-nav-cursor:focus {
  outline: 3px solid var(--accent);
  outline-offset: 3px;
}
@keyframes sz-nav-pulse {
  50% { outline-color: color-mix(in srgb, var(--accent) 40%, transparent); }
}
@media (prefers-reduced-motion: reduce) {
  .sz-nav-cursor { animation: none; }
}
