/* Neon Arena — game view: board column + sidebar, fighter cards, clocks,
   premove bar, moves list, fair-play card, emotes, rematch splash, fx layers.
   Loaded after components.css; reuses tokens and .glass-card/.btn primitives. */

/* === the match is one screenful, not a page ===
   The mock's match wrap is `height:100%; overflow:hidden` with the opponent
   bar pinned to the top, the player's own bar to the bottom and the board
   taking everything between them. `--match-h` and `--board-cap` are written
   by ui/board.js `fitMatchColumn()` from the SCENE's measured height — see
   the header there for why no CSS length can stand in for them. Both fall
   back to what shipped before, so a browser without ResizeObserver gets the
   old scrolling layout rather than a collapsed one. */
.view-game {
  position: relative;
  display: grid;
  grid-template-columns: minmax(0, 1fr) 340px;
  gap: 18px;
  align-items: stretch;
  overflow: visible;
  height: var(--match-h, auto);
  min-height: 0;
}

.g-board-col {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 10px;
  min-width: 0;
  height: 100%;
  min-height: 0;
}
/* Every row of the board column is fixed except the board, which takes what
   is left, and the result panel, which is allowed to eat into the board up to
   62% of the column and then scrolls inside itself. */
.g-board-col > * { flex: 0 0 auto; }
/* The board's floor is `--board-cap`, the number fitMatchColumn() leaves after
   the fixed rows, floored at MATCH_FIT_MIN_BOARD (ui/board.js) — so it is never
   smaller than a legible board. A stated minimum is definite whatever the
   column does; the `auto` flex basis it replaces is not. A signed-out spectator
   loses the #ctx column (ui/shell.js renderCtx() removes it), the scene is
   ~250px wider at one and the same window and stays above the one-column
   threshold below, and the two-column grid that leaves can carry more fixed
   rows than height. Every stage here that sizes itself FROM the frame rather
   than contributing a height of its own then resolved its basis to 0 and
   shrank to nothing — measured at 844x390 guest against authed at the same
   window: checkers, nardy, durak and arcomage `.board-frame` 0 tall (authed
   160-330), typing_race 24px (authed 404). Battleship's `.bs-stage` had the
   same hole and took the same floor scoped to `.view-bs` (battleship.css);
   this is the general rule that one left behind. The floor over-subscribes the
   column on purpose — a floor that cannot overflow is not a floor — and the
   scene scrolls, which is what the authed player at the same window has always
   got (their one-column column grows the same way). The one-column block below
   takes the floor back off: there the board takes its width-bound size, and a
   stage narrower than it is tall — nardy at 860x844 authed, a 356px stage
   against a 365px cap — would otherwise be centred down inside a taller frame.
   `60vh` is the fallback this file already gives the board's max-width, for a
   browser without ResizeObserver. */
.g-board-col > .board-frame { flex: 1 1 auto; min-height: var(--board-cap, 60vh); }
.g-board-col > .rp { flex: 0 1 auto; min-height: 0; max-height: 62%; }
/* The side column scrolls inside itself, the way the mock's context column
   does. Without this the fixed height would simply clip the move list. */
.g-side-col {
  display: flex;
  flex-direction: column;
  gap: 14px;
  min-width: 0;
  height: 100%;
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
}

/* === the side column, hoisted into the console's context column ===
   `hoistSideColumn()` (ui/board.js) moves `.g-side-col` out of the scene and
   into `#ctx` for the match views that ask for it, and the header there says
   why it is a per-view choice and not the default. Two rules follow from the
   move and neither is cosmetic:
   1. the grid must stop reserving the 340px track the column used to sit in,
      or the board column gains nothing;
   2. the column was sized to be a full-height flex child of a fixed-height
      grid; inside `#ctx` it is one block in a column that scrolls as a whole,
      so it gives up its own height and its own scroller.
   `#ctx` itself carries no padding and no gap by contract (shell.css), so the
   card gap and the inset are restated here rather than inherited. */
.view-game.is-side-hoisted { grid-template-columns: minmax(0, 1fr); }
#ctx > .g-side-col {
  flex: 0 0 auto;
  height: auto;
  overflow: visible;
  padding: 12px;
  border-bottom: 1px solid var(--hairline);
}
/* While a match owns the column its own blocks are what the column is FOR;
   leaving the shell's identity/fire/goal stack above them pushes the move log
   under the fold of a 660px column. It is hidden rather than removed, because
   the shell keeps re-rendering that node and must find it where it left it. */
#ctx.has-match > [data-shell="ctx-shell"] { display: none; }

/* === fighter cards ===
   Every value here is the mock's, taken from console.html §MATCH SCREENS
   rather than eyeballed: the bar is `gap:11px; padding:9px 13px;
   border-radius:13px; border:1px solid rgba(167,139,250,.2);
   background:rgba(18,12,34,.66)`, and the player's own bar swaps that for a
   gold border, the 115° amber wash and a 26px halo. */
.fighter-slot { display: block; }
.fighter {
  display: flex;
  align-items: center;
  gap: 11px;
  padding: 9px 13px;
  border-radius: 13px;
  border: 1px solid rgba(167, 139, 250, 0.2);
  background: rgba(18, 12, 34, 0.66);
  transition: border-color var(--dur-fast), box-shadow var(--dur-med);
}
.fighter.is-self {
  border-color: rgba(241, 193, 82, 0.4);
  background: linear-gradient(115deg, rgba(48, 32, 14, 0.6), rgba(18, 12, 34, 0.66));
  box-shadow: 0 0 26px rgba(241, 193, 82, 0.14);
}
.fighter.is-turn { border-color: rgba(241, 193, 82, 0.5); }
.fighter.is-self.is-turn { box-shadow: 0 0 26px rgba(241, 193, 82, 0.24); }
/* The «· ты» that used to hang off this name is gone. It was a Russian string
   hard-coded in `content:`, so it printed «· ты» in the English build too —
   and it is redundant three times over: this is the bar with the gold border,
   the gold clock and the «your move» line under the name. */
/* The avatar tile the mock puts at the head of both bars: 34px, r10, a violet
   well for the opponent and the brand gradient for the player. */
.fighter-avatar {
  flex: 0 0 auto;
  display: grid;
  place-items: center;
  width: 34px;
  height: 34px;
  border-radius: 10px;
  font-size: 16px;
  line-height: 1;
  background: linear-gradient(135deg, #241638, #120c22);
  border: 1px solid rgba(167, 139, 250, 0.28);
  user-select: none;
}
.fighter.is-self .fighter-avatar {
  background: linear-gradient(135deg, var(--violet-600), #241638);
  border-color: rgba(241, 193, 82, 0.44);
}
.fighter-id { min-width: 0; flex: 1 1 auto; display: flex; flex-direction: column; gap: 2px; }
.fighter-name {
  font-weight: 800;
  font-size: 12px;
  letter-spacing: 0.2px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.fighter-name.is-long { font-size: 11.5px; }
.fighter-name.is-xlong { font-size: 11px; }
/* Every game hangs per-turn badges in here (durak's role chip, the AI think
   badge). This row sits above the board, so a second line of text moves the
   board down and back up as the turn changes — keep it one line, always.
   `nowrap` is the fix; the clip only stops a long chip from painting over the
   clock. It has to be `clip` with a margin, not `hidden`, because the deltas
   and durak's attack chip glow 10-12px past their glyphs and `hidden` would
   shave that halo into a band. `hidden` stays first as the fallback for
   WebViews without overflow-clip-margin (pre-16.4 iOS): there the halo is
   clipped, which is what shipped before, rather than the chip spilling out. */
.fighter-sub {
  display: flex;
  align-items: baseline;
  gap: 6px;
  min-height: 15px;
  font-size: 10px;
  color: rgba(236, 231, 250, 0.44);
  white-space: nowrap;
  overflow: hidden;
  overflow: clip;
  overflow-clip-margin: 14px;
}
/* Grey, not gold. The mock's sub line is one dim string — «1 806 · [GRAM] ·
   🏅 Мастер эндшпиля» — with no colour inside it; the gold in this bar is
   spent on the player's own side and on the clock that is running. */
.fighter-rating { font-size: 10px; font-weight: 700; color: rgba(236, 231, 250, 0.44); }
.fighter-rating.is-uncal { color: var(--text-faint); }
/* What the mock's me-line says after the rating: «твой ход · ход 23». */
.fighter-turn { font-size: 10px; color: rgba(236, 231, 250, 0.44); }
.fighter.is-turn .fighter-turn { color: rgba(241, 193, 82, 0.8); }
.fighter-delta { font-size: 10px; font-weight: 700; }
.fighter-delta.is-preview { color: var(--text-faint); }
.fighter-delta.is-positive { color: var(--ok); text-shadow: 0 0 12px rgba(53, 199, 131, 0.5); }
.fighter-delta.is-negative { color: var(--danger); text-shadow: 0 0 12px rgba(255, 93, 108, 0.5); }
.fighter-delta.is-zero { color: var(--text-dim); }
.fighter-mid {
  display: flex;
  align-items: center;
  gap: 6px;
  flex: 0 1 auto;
  min-width: 0;
  overflow: hidden;
}
/* The other half of the jumping-board bug, and the worse half: this row fills
   up *during* a game — captures, fleet pips, tower counters — so wrapping meant
   the card grew a row mid-play and shoved the board down. A second row of
   14px icons is 6.55px, a third is 1.41px more, a row of arcomage's pills is
   10.75px. One row, always; `.fighter-mid` clips whatever does not fit, and the
   material total beside it still says how much was taken. */
.fighter-caps { display: flex; flex-wrap: nowrap; gap: 0; min-width: 0; }
/* Beat chessground's `.cg-wrap piece` (absolute, 12.5%) — caps are inline icons. */
.fighter .fighter-caps piece {
  position: static;
  display: inline-block;
  width: 17px;
  height: 17px;
  margin-right: -4px;
  background-size: contain;
  background-repeat: no-repeat;
  z-index: auto;
  will-change: auto;
  pointer-events: none;
  opacity: 0.92;
}
.caps-diff { font-size: 11px; font-weight: 800; color: var(--ok); }
/* The mock's clock: JetBrains Mono 23/700 in a flat pill, `padding:4px 13px;
   border-radius:9px`, violet well for the opponent and amber for you. */
.fighter-clock {
  flex: 0 0 auto;
  font-family: var(--font-mono);
  font-weight: 700;
  font-size: 23px;
  line-height: 1.4;
  font-variant-numeric: tabular-nums;
  letter-spacing: 0;
  padding: 4px 13px;
  border-radius: 9px;
  background: rgba(167, 139, 250, 0.09);
  border: 1px solid transparent;
  color: rgba(236, 231, 250, 0.72);
  transition: color var(--dur-fast), border-color var(--dur-fast), box-shadow var(--dur-med);
}
.fighter.is-self .fighter-clock { background: rgba(241, 193, 82, 0.16); color: var(--gold); }
/* The mock hangs `ac-breathe` (opacity .5 → 1) on the running clock. Taken as
   a HALO here and not as opacity: these digits change every second and half a
   clock is the one thing on this screen that must never be hard to read. */
.fighter.is-self.is-turn .fighter-clock {
  box-shadow: 0 0 18px rgba(241, 193, 82, 0.3);
  animation: clockBreathe 2s ease-in-out infinite;
}
.fighter.is-turn:not(.is-self) .fighter-clock { color: rgba(236, 231, 250, 0.92); }
.fighter-clock.panic { color: var(--gold); }
.fighter-clock.critical {
  color: var(--danger);
  background: rgba(255, 93, 108, 0.14);
  border-color: rgba(255, 93, 108, 0.55);
  animation: glowPulseDanger 1s ease-in-out infinite;
}
.fighter.panic { border-color: rgba(255, 93, 108, 0.35); }

@keyframes clockBreathe {
  0%, 100% { box-shadow: 0 0 10px rgba(241, 193, 82, 0.18); }
  50% { box-shadow: 0 0 22px rgba(241, 193, 82, 0.42); }
}

@keyframes glowPulseDanger {
  0%, 100% { box-shadow: 0 0 6px rgba(255, 93, 108, 0.35); }
  50% { box-shadow: 0 0 18px rgba(255, 93, 108, 0.75); }
}

/* === board ===
   The mock has no card under the board: the board itself carries the chrome
   — `border-radius:14px; border:1px solid rgba(241,193,82,.28);
   box-shadow:0 26px 70px rgba(0,0,0,.62), 0 0 60px rgba(124,58,237,.2)`.
   The frame is now only the box that centres it in the space the column has
   left over. */
.board-frame {
  position: relative;
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  border: 0;
  background: none;
  box-shadow: none;
  transition: box-shadow var(--dur-med);
}
.board-frame.is-lowtime .board-stage {
  animation: lowTimePulse 1.1s ease-in-out infinite;
}
@keyframes lowTimePulse {
  0%, 100% { box-shadow: var(--board-shadow), 0 0 0 rgba(255, 93, 108, 0); }
  50% { box-shadow: var(--board-shadow), 0 0 34px rgba(255, 93, 108, 0.5); }
}
.board-stage {
  --board-shadow: 0 26px 70px rgba(0, 0, 0, 0.62), 0 0 60px rgba(124, 58, 237, 0.2);
  position: relative;
  /* Square, and sized by whichever of the two axes runs out first: the column
     width, or what `fitMatchColumn()` measured as free height. The old
     `min(60vh, 640px)` asked the window and overshot the scene by ~100px. */
  width: 100%;
  max-width: min(var(--board-cap, 60vh), 640px);
  margin: 0 auto;
  aspect-ratio: 1 / 1;
  border-radius: 14px;
  border: 1px solid rgba(241, 193, 82, 0.28);
  box-shadow: var(--board-shadow);
  /* visible, not hidden: the emote bubbles rise out of this box on purpose. */
  overflow: visible;
  /* Touch hygiene, inert for a mouse: no grey iOS tap flash on the stage, no
     long-press callout/magnifier over a piece mid-drag, no text selection
     sweeping the coordinate labels. The squares themselves are the vendor's;
     these cover our stage, coords and emote layers around them. */
  -webkit-tap-highlight-color: transparent;
  -webkit-touch-callout: none;
  -webkit-user-select: none;
  user-select: none;
}
.cg-board { width: 100%; height: 100%; border-radius: 13px; overflow: hidden; }
/* A PLAYER's board owns its touches outright: with the scene scrolling under a
   width-bound board, a drag that starts on an empty square must not pan the
   page out from under the piece (chessground only preventDefault()s when a
   piece is under the finger). `manipulable` is chessground's own wrap class
   for `!viewOnly`, so spectators and replays keep scrolling straight through
   the board; a player scrolls from the bars, the foot or the sidebar. */
.board-stage .cg-board.manipulable { touch-action: none; }

/* File letters, put back under their own files.

   chessground ships `coords.files { left: 24px; width: 100% }` — it expects a
   wrap with room on the left for the rank numbers, which our board does not
   have. The row therefore started 24px right of the a-file and ran 24px past
   the h-file: every letter sat under its neighbour, and the H was clipped by
   the `overflow: hidden` above. On a 390px phone that offset is more than half
   a square. `bottom: -4px` cost the letters their lower 4px to the same clip.

   Measured at 390 and 1280 before and after. This is one board shared by
   /game, /ai and /replay, so it was wrong in all three.

   The vendor sheet is injected at runtime (board.js `ensureBoardCss`), which
   puts it after ours in the cascade, so an equal-specificity override would
   lose the tie. `.board-stage` is the extra class that wins it. */
.board-stage .cg-board coords.files {
  left: 0;
  bottom: 0;
  /* Second half of the same repair, found while measuring the board against
     the mock: centred under its own file, a letter lands on the piece
     standing on rank 1 — a1, d1, e1 and g1 were unreadable in the opening
     position at both 390 and 1280. Pushed into the corner (the lichess
     convention) it clears the glyph, and the shadow covers the rest. */
  align-items: flex-end;
  text-align: right;
}
.board-stage .cg-board coords.files coord {
  padding-right: 4px;
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.55);
}
/* And the rank numbers, for the same reason and with the same measurement:
   chessground lifts the whole column 20px (`coords.ranks { top: -20px }`) and
   then nudges every number down 39% of its own cell, which parks each one
   near the top of its rank — except the 8, which lands ABOVE the board and is
   cut off by the `overflow: hidden` on .cg-board. Measured at 390 and 1280:
   the 8 was missing on all three screens. Dropped back to `top: 0` with the
   nudge removed, which is where the mock draws them: top-left of each rank. */
.board-stage .cg-board coords.ranks { top: 0; }
.board-stage .cg-board coords.ranks coord {
  transform: none;
  padding-top: 1px;
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.55);
}

/* chessground neon tint: keep brown theme, add a gold-ish selection glow */
.cg-board cg-board { border-radius: var(--r-sm); }
.cg-board square.last-move { background-color: rgba(241, 193, 82, 0.28); }
.cg-board square.check { background: radial-gradient(circle, rgba(255, 93, 108, 0.55) 22%, rgba(255, 93, 108, 0) 72%); }

/* ---- table cosmetics (services_cosmetics): bought flair on the table ----
   board.js toggles these wrapper classes from the game payload's per-seat
   `equipped`; the rules below do the painting. They outrank chessground's own
   sheet, which ensureBoardCss() injects at runtime (after ours) — `.cg-board
   .skin-* cg-board` scores (0,2,1) against the vendor's bare `cg-board`
   (0,0,1), so the tie the coords repair above had to win on source order does
   not apply here. */

/* Emerald board skin. The brown checkerboard is an SVG baked into
   chessground.brown.css; the skin paints over it with an emerald/obsidian
   checkerboard (light #47a178 / dark #1b3b2e) and re-tints the last-move
   highlight a brighter gold so it still reads on the darker squares. The
   cburnett pieces are separate elements drawn on top, so they are untouched —
   cream whites and outlined blacks both clear a medium/dark green. */
.cg-board.skin-emerald cg-board {
  background-color: #1b3b2e;
  background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiPjxkZWZzPjxwYXR0ZXJuIGlkPSJwbXctZW1lcmFsZCIgd2lkdGg9IjIiIGhlaWdodD0iMiIgcGF0dGVyblVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+PHJlY3Qgd2lkdGg9IjIiIGhlaWdodD0iMiIgZmlsbD0iIzQ3YTE3OCIvPjxyZWN0IHdpZHRoPSIxIiBoZWlnaHQ9IjEiIGZpbGw9IiMxYjNiMmUiLz48cmVjdCB4PSIxIiB5PSIxIiB3aWR0aD0iMSIgaGVpZ2h0PSIxIiBmaWxsPSIjMWIzYjJlIi8+PC9wYXR0ZXJuPjwvZGVmcz48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSJ1cmwoI3Btdy1lbWVyYWxkKSIvPjwvc3ZnPg==");
}
.cg-board.skin-emerald square.last-move { background-color: rgba(255, 220, 110, 0.40); }

/* Comet move trail. The trail owner's last move glows violet-gold instead of
   the flat gold tint — a warm head over a violet tail. Sits after the skin so
   that when a trail owner plays on a skinned board the comet wins the
   last-move tie (both (0,3,1); source order decides). */
.cg-board.trail-comet square.last-move {
  background: radial-gradient(circle, rgba(255, 220, 120, 0.60) 0%, rgba(167, 139, 250, 0.42) 48%, rgba(124, 92, 250, 0) 74%);
  box-shadow: inset 0 0 10px rgba(167, 139, 250, 0.50);
}

/* countdown line (abort timer / finished result).
   `:empty` collapses instead of reserving 20px: the mock has no such row at
   all, and during a normal game this line says nothing for minutes at a
   stretch — 30px of board given away to a blank. It still reserves its
   height the moment it has something to say, so the board does not jump when
   the abort timer starts. */
.board-countdown {
  flex: 0 0 auto;
  min-height: 20px;
  text-align: center;
  font-size: 13px;
  font-weight: 700;
  letter-spacing: 0.2px;
  color: var(--text-dim);
}
.board-countdown:empty { display: none; }
.board-countdown[data-tone="danger"] { color: var(--danger); text-shadow: 0 0 14px rgba(255, 93, 108, 0.5); }
.board-countdown[data-tone="gold"] { color: var(--gold); }

/* premove bar */
.premove-bar {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 8px 12px;
  border-radius: var(--r-md);
  border: 1px dashed rgba(53, 199, 131, 0.45);
  background: rgba(53, 199, 131, 0.07);
}
.premove-text {
  flex: 1 1 auto;
  font-size: 12.5px;
  color: var(--text-dim);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.premove-actions { display: flex; gap: 6px; }

/* waiting overlay (status = waiting) */
.board-waiting {
  position: absolute;
  inset: 0;
  z-index: 5;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 16px;
  border-radius: 13px;
  background: rgba(6, 9, 12, 0.66);
  -webkit-backdrop-filter: blur(6px);
  backdrop-filter: blur(6px);
}
.board-waiting-text { font-weight: 800; font-size: 16px; color: var(--gold); text-shadow: 0 0 16px rgba(241, 193, 82, 0.5); }

/* emote bubbles floating up the board edges */
.board-emotes {
  position: absolute;
  inset: -6% -12%;
  z-index: 7;
  pointer-events: none;
  overflow: visible;
}
.g-emote {
  position: absolute;
  top: 92%;
  font-size: clamp(20px, 4.2vw, 32px);
  line-height: 1;
  opacity: 0;
  animation: gEmoteFloat 10s ease-in forwards;
  filter: drop-shadow(0 12px 20px rgba(0, 0, 0, 0.45));
  user-select: none;
  white-space: nowrap;
  will-change: top, opacity;
}
.g-emote.left { left: 4%; }
.g-emote.right { right: 4%; }
.g-emote.spec { left: 50%; transform: translateX(-50%); }
@keyframes gEmoteFloat {
  0% { top: 92%; opacity: 0; }
  12% { opacity: 0.95; }
  88% { opacity: 0.95; }
  100% { top: -10%; opacity: 0; }
}

/* === controls ===
   Three button kinds, and the mock states all three exactly: gold is
   `padding:9px 20px; r10; linear-gradient(135deg,#f1c152,#d99a26); #1a1204;
   12/800; 0 6px 18px rgba(241,193,82,.28)`, danger and plain are `9px 16px;
   r10; 11/700` over a hairline. Scoped to the match rows so nothing else in
   the app inherits a second button vocabulary. */
.g-controls { flex: 0 0 auto; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
/* One row under the board: the game actions on the left (while they are not
   up inside the bar) and the utilities pushed to the right, so the two never
   cost the board two lines. */
.g-actions-row { flex: 0 0 auto; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.g-controls-slot { display: flex; min-width: 0; }
.g-controls-slot:empty { display: none; }
/* Inside the bar: the mock's order is identity, then the buttons, then the
   clock — which is why this box sits before .fighter-clock in the markup. */
.fighter-acts { flex: 0 0 auto; display: flex; align-items: center; gap: 8px; min-width: 0; }
.fighter-acts:empty { display: none; }
.fighter-acts .g-controls { flex-wrap: nowrap; }
/* Sound, haptics, fullscreen, resync. Utilities, not game actions: the mock
   has no row for them at all, so they get the quietest one that still keeps
   every control one tap away. */
.g-tools { gap: 6px; justify-content: flex-end; margin-left: auto; }
.g-tools .icon-btn { width: 34px; height: 34px; min-height: 34px; font-size: 14px; }
/* THE 34 IS A DESKTOP NUMBER AND ITS `min-height` CANCELS THE TOUCH FLOOR.
   base.css's `.icon-btn:not([data-tight])` is (0,2,0) and the rule above is
   (0,2,0) too, so source order alone decided it — and views/ load after
   base.css, so `min-height: 34px` won everywhere the floor applies.
   The 44 was restored, but under `@container scene (max-width: 619px)` below,
   and a container query cannot answer «is this a thumb?». It asks how wide the
   SCENE is, and the scene's width depends on whether the player is signed in:
   at one and the same 860px window an authed player's scene is 546px (the
   context column takes 252) and fires the rule, while an anonymous spectator's
   is 798px and does not. Measured on /game at 860x844 — authed 44px tall,
   guest 34px, same window, same screen, the sound/haptics/fullscreen/resync
   row of a live match.
   Same query as base.css verbatim, so the two can never drift; height only,
   because `.land-match` below states the width case for the phone held
   sideways and its reasoning (34 wide or the action row wraps and eats the
   board) still stands — it already sets 44 tall, so this changes nothing
   there. */
@media (max-width: 904px), (max-height: 500px) {
  .g-tools .icon-btn:not([data-tight]) { min-height: 44px; }
}
.g-controls .btn,
.rp-transport .btn {
  white-space: nowrap;
  padding: 9px 16px;
  border-radius: 10px;
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0;
  box-shadow: none;
}
.g-controls .btn-ghost,
.rp-transport .btn-ghost {
  border: 1px solid rgba(167, 139, 250, 0.26);
  background: rgba(20, 13, 38, 0.55);
  color: rgba(236, 231, 250, 0.7);
}
.g-controls .btn-danger {
  border: 1px solid rgba(255, 93, 108, 0.34);
  background: rgba(255, 93, 108, 0.07);
  color: #ff8a94;
}
.g-controls .btn-primary,
.rp-side-actions .btn-primary {
  padding: 9px 20px;
  border: 0;
  background: var(--gold-grad);
  color: var(--gold-ink);
  font-size: 12px;
  font-weight: 800;
  box-shadow: 0 6px 18px rgba(241, 193, 82, 0.28);
}
.g-controls .icon-btn {
  border: 1px solid rgba(167, 139, 250, 0.26);
  background: rgba(20, 13, 38, 0.55);
  border-radius: 10px;
}
.g-controls.finished-tools { margin-top: -2px; }
.icon-btn.spinning { animation: spin 0.9s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }

button.is-draw-offer,
.btn.is-draw-offer {
  position: relative;
  z-index: 0;
  border-color: transparent !important;
  background:
    linear-gradient(var(--panel-strong), var(--panel-strong)) padding-box,
    var(--draw-rainbow) border-box !important;
  background-size: 100% 100%, 240% 100% !important;
  background-position: 0 0, 0 50% !important;
  animation: drawRainbowBorder 2.8s linear infinite;
  box-shadow: 0 12px 22px rgba(0, 0, 0, 0.32), 0 0 18px rgba(124, 201, 255, 0.28);
}
@keyframes drawRainbowBorder {
  to { background-position: 0 0, 240% 50%; }
}

/* emote picker */
.emote-wrap { position: relative; }
.emote-panel {
  position: absolute;
  bottom: calc(100% + 8px);
  right: 0;
  z-index: var(--z-overlay);
  padding: 10px;
  border-radius: var(--r-md);
  background: var(--panel-strong);
  border: 1px solid var(--stroke-strong);
  box-shadow: var(--shadow-pop);
  animation: popIn var(--dur-med) var(--spring) both;
}
.emote-grid { display: grid; grid-template-columns: repeat(5, 44px); gap: 6px; }
.emote-choice {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  border-radius: var(--r-sm);
  border: 1px solid var(--stroke);
  background: var(--panel-soft);
  font-size: 22px;
}
@media (hover: hover) {
  .emote-choice:hover:not(:disabled) { transform: translateY(-2px) scale(1.06); border-color: var(--stroke-strong); }
}
.emote-choice:disabled { opacity: 0.4; cursor: not-allowed; }
/* The hint belongs to the picker, so it goes away with it. Left standing it
   was a permanent 21px line under the buttons explaining a panel that was
   not on screen — 21px the board could have had. */
.emote-hint { margin-top: 4px; font-size: 11.5px; color: var(--text-faint); text-align: right; }
.emote-panel.hidden + .emote-hint { display: none; }

/* === sidebar: status / moves / fair play / emote log === */
.g-meta { font-size: 13.5px; font-weight: 600; color: var(--text-dim); }
.spec-bar { margin-top: 10px; }
.spec-pill {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  flex-wrap: wrap;
  padding: 6px 12px;
  border-radius: var(--r-pill);
  border: 1px solid rgba(124, 201, 255, 0.3);
  background: rgba(124, 201, 255, 0.08);
  color: var(--cool);
  font-size: 12.5px;
  font-weight: 700;
}
.spec-pill.pulse { animation: popIn var(--dur-med) var(--spring) both; }
.spec-tag {
  padding: 2px 8px;
  border-radius: var(--r-pill);
  background: rgba(124, 201, 255, 0.14);
  font-size: 11px;
  font-weight: 600;
}
.spec-tag.is-more { opacity: 0.8; }

.moves-card .card-title { margin-bottom: 8px; }
.hist-controls { display: flex; gap: 4px; }
.hist-btn {
  min-width: 34px;
  height: 30px;
  padding: 0 6px;
  border-radius: var(--r-sm);
  border: 1px solid var(--stroke);
  background: var(--panel-soft);
  color: var(--text-dim);
  font-size: 13px;
  font-weight: 700;
  transition: color var(--dur-fast), border-color var(--dur-fast), transform var(--dur-med) var(--spring);
}
@media (hover: hover) {
  .hist-btn:hover:not(:disabled) { color: var(--text); border-color: var(--stroke-strong); }
}
.hist-btn:active:not(:disabled) { transform: scale(0.92); }
.hist-btn:disabled { opacity: 0.35; cursor: not-allowed; }
/* The width half of base.css's 44px touch floor (same query verbatim, so the
   two can never drift). base.css forces min-height: 44 at (0,1,1) — the
   `height: 30` above loses to it — but names no width, so the history keys
   rendered 34×44 tall slivers on every phone. `:not([data-tight])` keeps the
   escape hatch AND keeps this at (0,2,0), so `.land-match .hist-btn` (further
   down, same specificity) still wins on source order and keeps its deliberate
   28px density in phone-landscape. */
@media (max-width: 904px), (max-height: 500px) {
  .hist-btn:not([data-tight]) { min-width: 44px; }
}
.hist-hint { font-size: 11.5px; color: var(--gold); font-weight: 700; margin-bottom: 6px; }
.moves-list {
  display: flex;
  flex-direction: column;
  gap: 2px;
  max-height: 260px;
  overflow-y: auto;
  padding-right: 4px;
}
.mv-row {
  display: grid;
  grid-template-columns: 34px 1fr 1fr;
  gap: 6px;
  align-items: center;
  padding: 2px 4px;
  border-radius: var(--r-sm);
}
.mv-row:nth-child(odd) { background: var(--panel-soft); }
.mv-n { font-size: 12px; color: var(--text-faint); font-weight: 700; }
.mv-move {
  font-size: 13px;
  font-weight: 600;
  padding: 3px 8px;
  border-radius: var(--r-sm);
  cursor: pointer;
  transition: background var(--dur-fast), color var(--dur-fast);
}
@media (hover: hover) {
  .mv-move:hover { background: var(--stroke); }
}
.mv-move.active {
  background: rgba(241, 193, 82, 0.18);
  color: var(--gold);
  box-shadow: inset 0 0 0 1px rgba(241, 193, 82, 0.4);
}
.mv-move.is-empty { cursor: default; }

/* fair play */
.fair-card { border-color: rgba(167, 139, 250, 0.28); }
.fair-badge {
  padding: 4px 11px;
  border-radius: var(--r-pill);
  font-size: 11px;
  font-weight: 800;
  letter-spacing: 0.3px;
}
.fair-badge.is-odds {
  background: rgba(167, 139, 250, 0.14);
  color: var(--violet);
  border: 1px solid rgba(167, 139, 250, 0.45);
  box-shadow: var(--shadow-glow-violet);
}
.fair-badge.is-even { background: rgba(53, 199, 131, 0.12); color: var(--ok); border: 1px solid rgba(53, 199, 131, 0.4); }
.fair-badge.is-disabled { background: var(--panel-soft); color: var(--text-faint); border: 1px solid var(--stroke); }
.fair-summary { font-size: 13.5px; font-weight: 700; }
.fair-sub { margin-top: 6px; font-size: 12px; color: var(--text-faint); }
.fair-facts { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
.fair-chip {
  padding: 4px 10px;
  border-radius: var(--r-pill);
  border: 1px solid var(--stroke);
  background: var(--panel-soft);
  font-size: 11.5px;
  font-weight: 700;
  color: var(--text-dim);
}
.fair-why { margin-top: 10px; font-size: 12.5px; }
.fair-why summary { cursor: pointer; color: var(--cool); font-weight: 700; }
.fair-why-line { margin-top: 6px; color: var(--text-dim); }

/* emote log */
.emote-log { display: flex; flex-direction: column; gap: 6px; }
.emote-log-row {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 7px 10px;
  border-radius: var(--r-sm);
  border: 1px solid var(--stroke);
  background: var(--panel-soft);
  animation: popIn var(--dur-med) var(--spring) both;
}
.emote-log-emoji { font-size: 19px; }
.emote-log-who { font-size: 12.5px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.emote-log-at { font-size: 11px; color: var(--text-faint); font-variant-numeric: tabular-nums; }

/* anon banner */
.anon-card { border-color: rgba(124, 201, 255, 0.3); }

/* === promotion modal === */
.promo-modal { text-align: center; width: min(94vw, 420px); }
.promo-title { font-size: 19px; font-weight: 800; margin-bottom: 16px; }
.promo-row { display: flex; justify-content: center; gap: 10px; }
.promo-piece {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 72px;
  height: 72px;
  border-radius: var(--r-md);
  border: 1px solid var(--stroke);
  background: var(--panel-soft);
}
@media (hover: hover) {
  .promo-piece:hover { border-color: var(--gold); box-shadow: var(--shadow-glow-gold); transform: translateY(-3px); }
}
.promo-piece:focus-visible { border-color: var(--gold); box-shadow: var(--shadow-glow-gold); }
.promo-modal .promo-piece piece {
  position: static;
  display: block;
  width: 56px;
  height: 56px;
  background-size: contain;
  background-repeat: no-repeat;
  z-index: auto;
  will-change: auto;
  pointer-events: none;
}
.promo-key {
  position: absolute;
  right: 5px;
  bottom: 4px;
  font-size: 10px;
  font-weight: 800;
  color: var(--text-faint);
}
.promo-hint { margin-top: 14px; font-size: 12px; color: var(--text-faint); }

/* === resign modal === */
.resign-modal { text-align: center; }
.resign-flag { font-size: 46px; display: inline-block; }
.resign-title { font-size: 21px; font-weight: 800; margin: 12px 0 8px; }
.resign-sub { color: var(--text-dim); font-size: 14px; margin-bottom: 20px; }
.resign-actions { display: grid; gap: 10px; }

/* === rematch splash (full takeover of the view) === */
/* Fixed, not absolute. `inset: 0` on an absolute box means the whole VIEW,
   and at 390px the view is 1494px tall — so the takeover centred itself 747px
   down and the player got the title with the two buttons a scroll below the
   fold, on a screen that exists to ask a yes/no question. Measured at
   390x844 before the change: splash box 1472px, frame centre y=736 against a
   702px scene. Above the topbar on purpose: it is a modal, and half a modal
   under the hud is worse than a modal over it. */
.rematch-splash {
  position: fixed;
  inset: 0;
  z-index: var(--z-modal);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 18px;
  background: rgba(4, 7, 11, 0.78);
  -webkit-backdrop-filter: blur(12px);
  backdrop-filter: blur(12px);
  opacity: 0;
  pointer-events: none;
  transform: scale(0.985);
  transition: opacity var(--dur-med) var(--ease-out), transform var(--dur-med) var(--ease-out);
}
.rematch-splash.is-active { opacity: 1; pointer-events: auto; transform: scale(1); }
.rs-frame {
  position: relative;
  width: min(560px, 94%);
  padding: clamp(22px, 4vw, 36px);
  border-radius: var(--r-xl);
  border: 1px solid rgba(53, 199, 131, 0.5);
  background: var(--panel-strong);
  box-shadow: var(--shadow-card), inset 0 0 30px rgba(53, 199, 131, 0.16), var(--shadow-glow-ok);
  text-align: center;
  overflow: hidden;
  animation: rematchPulse 1.4s ease-in-out infinite;
}
.rs-kicker {
  font-size: 11px;
  letter-spacing: 0.45em;
  text-transform: uppercase;
  color: rgba(53, 199, 131, 0.8);
  font-weight: 800;
  margin-bottom: 12px;
}
.rs-title {
  font-family: var(--font-display);
  font-size: clamp(26px, 5vw, 42px);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  font-weight: 800;
  color: #ffe3e3;
  text-shadow: 0 0 22px rgba(255, 93, 108, 0.8), 0 0 38px rgba(255, 93, 108, 0.45);
}
.rs-sub { margin-top: 12px; font-size: clamp(12.5px, 2.8vw, 15.5px); color: var(--text-dim); }
.rs-status {
  margin-top: 14px;
  font-size: 12px;
  letter-spacing: 0.24em;
  text-transform: uppercase;
  color: var(--gold);
  font-weight: 800;
}
.rs-actions { display: grid; gap: 10px; margin-top: 20px; }
.rs-yes { box-shadow: 0 0 22px rgba(53, 199, 131, 0.3); }
.rematch-splash.is-waiting .rs-frame {
  border-color: rgba(241, 193, 82, 0.65);
  box-shadow: var(--shadow-card), inset 0 0 30px rgba(241, 193, 82, 0.18), var(--shadow-glow-gold);
}
.rematch-splash.is-refused { }
.rematch-splash.is-refused .rs-frame {
  border-color: rgba(255, 93, 108, 0.7);
  box-shadow: var(--shadow-card), inset 0 0 30px rgba(255, 93, 108, 0.22), var(--shadow-glow-danger);
  animation: none;
}
.rematch-splash.is-refused .rs-title { text-shadow: 0 0 18px rgba(255, 93, 108, 0.65); }
@keyframes rematchPulse {
  0%, 100% { transform: scale(1); }
  50% { transform: scale(1.02); }
}

/* === fx layers === */
/* .view > * entrance stagger holds `both` fill (opacity 1, translateY 0) — it
   must never fight the splash visibility or shift the fx canvas. */
.view-game > .fx-canvas,
.view-game > .rematch-splash { animation: none !important; }
.fx-canvas {
  position: absolute;
  inset: 0;
  z-index: 6;
  pointer-events: none;
}
.fx-shake { animation: fxShake 0.42s var(--ease-out); }
.fx-shake-big { animation: fxShakeBig 0.5s var(--ease-out); }
@keyframes fxShake {
  0%, 100% { transform: translate(0, 0); }
  20% { transform: translate(-5px, 2px); }
  40% { transform: translate(4px, -2px); }
  60% { transform: translate(-3px, -1px); }
  80% { transform: translate(2px, 1px); }
}
@keyframes fxShakeBig {
  0%, 100% { transform: translate(0, 0); }
  15% { transform: translate(-8px, 3px); }
  30% { transform: translate(7px, -3px); }
  45% { transform: translate(-6px, 2px); }
  60% { transform: translate(5px, -2px); }
  75% { transform: translate(-3px, 1px); }
  90% { transform: translate(2px, -1px); }
}

/* === responsive: one column when the SCENE is too narrow for two ===
   Was `@media (max-width: 860px)`, which asks the window. The console
   subtracts the rail and the context column before the scene gets anything,
   so the two numbers diverge by up to 378px and the query fired at the wrong
   times in both directions: at a 905px window the scene is ~527px and this
   block did NOT fire, leaving a two-column layout inside it and squeezing the
   board to a third of its phone size; the same blindness kept two columns at
   a scene of 390.
   620 is the budget shell.css states for two columns, in scene pixels. Do not
   copy 860 here: at a 1280px window the scene is 858, and 858 < 860 would
   collapse the widest layout in the product. */
@container scene (max-width: 619px) {
  /* One column — and the fixed height moves off the view onto the board
     column alone. The mock's portrait match IS the screen (opponent bar,
     board, own bar, actions, reactions); everything our sidebar carries that
     the mock's phone does not — the move list, the chat card, fair play —
     keeps existing one scroll below it rather than being deleted. */
  .view-game { grid-template-columns: 1fr; height: auto; }
  /* min-height, not height. The column has to be able to GROW here: when the
     game ends the result panel joins it, and pinned to the screen's height
     the panel, the buttons and the reaction strip printed on top of each
     other. Pinned as a minimum, the empty case still fills the screen and the
     full case scrolls. */
  .g-board-col { min-height: var(--match-h, 0); height: auto; }
  /* The mock's portrait body keeps the two bars against the board and puts
     the foot — actions, then reactions — at the bottom of the screen. So the
     board takes its natural (width-bound) size here and the slack goes into
     one gap above the foot instead of two around the board. `min-height` back
     to 0: the `--board-cap` floor above exists for the over-subscribed
     two-column grid, and here the column grows, so the floor would only
     centre a width-bound stage down inside a taller frame (measured at
     860x844 authed: nardy's 356px stage against a 365px cap — the board
     would have moved 9px down; durak's 270px stage, 133px). */
  .g-board-col > .board-frame { flex: 0 0 auto; min-height: 0; }
  /* The column's 10px was designed for the two-column desktop where gaps are
     free; here five of them stand between the board and its full width.
     7px × 4 visible gaps buys the foot ~12px back on every phone. */
  .g-board-col { gap: 7px; }
  /* Zero pixels tall, one gap wide: the closed emote popover is still a flex
     item and still charges the column a `gap` (fitMatchColumn counts it for
     exactly that reason). Its door is hidden on the phone (`.g-tools` below)
     and the reaction strip IS the picker here, so the wrap has no phone duty —
     same deletion the landscape block already makes for the same reason. */
  .view-game .g-board-col > .emote-wrap { display: none; }
  .g-actions-row { margin-top: auto; }
  .g-side-col { order: 2; height: auto; min-height: 0; overflow: visible; }
  .moves-list { max-height: 190px; }
  /* mock, phone: avatar 30, name 11, sub 9, clock 19/700 in `3px 10px`.
     Vertical padding 6, not the mock's 8: the pair of bars pays 4px back to
     the board and the 30px avatar still clears the box. The clock and type
     sizes are the readability floor — do not shave those. */
  .fighter { gap: 9px; padding: 6px 11px; border-radius: 12px; }
  .fighter-avatar { width: 30px; height: 30px; border-radius: 9px; font-size: 14px; }
  .fighter-name { font-size: 11px; }
  .fighter-name.is-long { font-size: 10.5px; }
  .fighter-name.is-xlong { font-size: 10px; }
  .fighter-sub, .fighter-rating, .fighter-delta, .fighter-turn { font-size: 9px; }
  .fighter-clock { font-size: 19px; padding: 3px 10px; border-radius: 8px; }
  .board-stage {
    --board-shadow: 0 18px 50px rgba(0, 0, 0, 0.6);
    border-radius: 12px;
    border-color: rgba(241, 193, 82, 0.26);
    /* WIDTH-bound at last. The base rule's `min(var(--board-cap), 640px)` is
       the two-column number: leftover height after every sibling row. Here the
       column is allowed to GROW (see `.g-board-col` above) and the scene
       scrolls the slack, so the height cap only shrank the board — measured at
       430x932 Safari it held a 410px-wide column to a ~330px board. With the
       cap gone the stage takes the column width (`width:100%` + aspect-ratio),
       and the foot is at worst one small flick away; the sticky reaction strip
       below stays on-screen regardless. */
    max-width: none;
  }
  .cg-board { border-radius: 11px; }
  .board-waiting { border-radius: 11px; }
  .board-emotes { inset: -4% -6%; }
  .g-emote.left { left: 2%; }
  .g-emote.right { right: 2%; }
  /* The mock's phone foot: the actions take the full row at a 44px floor and
     the utilities drop under them. */
  .g-controls { gap: 6px; }
  .g-controls-slot { flex: 1 1 100%; }
  .g-controls-slot .g-controls { flex: 1 1 auto; }
  /* `min-width: 0` or the flex base never shrinks below the label's
     min-content and «Новая партия» hangs off the right edge of the column. */
  .g-controls .btn {
    flex: 1 1 0;
    min-width: 0;
    min-height: 44px;
    padding: 10px 10px;
    overflow: hidden;
    text-overflow: ellipsis;
  }
  /* The reaction strip replaces the reactions CARD down here: the mock puts
     the six emoji and the chat door in one scrolling row under the buttons,
     which is one tap to send instead of two through a popover. */
  .emote-card { display: none; }
  /* Beats the `display:none` default further down this file, which is later
     in source order and would otherwise win the tie.

     STICKY, because the strip is the one piece of the foot that must never
     leave the screen: the reaction packs people buy are sent from here, and a
     width-bound board on a toolbar-shortened Safari viewport pushes the
     column's tail below the fold. `bottom: 0` against #scene (the app's only
     scroller) means the strip rides the viewport edge while the column is
     taller than the screen and settles into its natural seat once the player
     scrolls the ~40-60px of slack — на просторных экранах (16 Pro Max, PWA,
     fullscreen) правило вообще не срабатывает. The glass slab is the tabbar's
     recipe; without it the pinned strip would float ten emoji straight over
     the resign button. */
  .view-game .g-react-row {
    display: flex;
    position: sticky;
    bottom: 0;
    z-index: 12;
    padding: 6px 8px;
    margin: 0 -4px;
    border-radius: 12px 12px 0 0;
    border: 1px solid var(--hairline);
    border-bottom: 0;
    background: var(--panel-strong);
    -webkit-backdrop-filter: blur(14px) saturate(1.3);
    backdrop-filter: blur(14px) saturate(1.3);
  }
  /* The touch floor's missing width half. base.css's 44px floor is height-only
     for plain buttons, so these rendered 32-wide × 44-tall slivers — under the
     floor on exactly the axis a thumb sweeps the scroller along. The strip
     scrolls sideways, so wider squares cost scroll distance, not layout; the
     emoji grows with the box or it rattles inside it. `.view-game` prefix for
     the same reason the display:flex above carries it: the strip's base rules
     sit LATER in this file and win a bare-class tie on source order. */
  .view-game .g-react-btn { width: 44px; height: 44px; border-radius: 11px; font-size: 19px; }
  .view-game .g-react-door { height: 44px; }
  /* The mock's phone foot is the two game buttons, then the reaction strip
     with the chat door pinned to its end — and nothing else. The build used
     to carry a second row here: the emote popover plus the sound / haptics /
     fullscreen / resync toggles, each with a text label. That row is not in
     the mock, and on a 390px screen it cost the board a whole line for
     utilities a phone player reaches from settings (and from the desktop act
     bar), so it leaves the phone foot to match the mock. The reaction strip
     already replaces the emote popover, so its button goes with the row; the
     four toggles keep working on desktop, where this block does not apply. */
  .view-game .g-tools { display: none; }
  /* `emote-btn` is still minted in views/game.js (it rides `.g-tools`, hidden
     above), so the class needs a rule or the SPA class-coverage ratchet flags
     it as styled-but-orphaned. The rule is a no-op on the phone — the parent
     `.g-tools` is `display: none` — and inert on desktop, where the popover
     door lives in the tools row as before; it exists to keep the markup and
     the sheet in agreement after the tools row left the phone foot. */
  .view-game .g-controls .emote-btn { display: none; }
}

/* === the reaction strip (phone only; see the container query above) ===
   `flex:0 0 auto` squares at 32px in a row that scrolls sideways, then the
   chat door pinned to its end — the mock's `ph.mReact`. */
.g-react-row {
  display: none;
  flex: 0 0 auto;
  align-items: center;
  gap: 5px;
}
.g-react-scroll {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  align-items: center;
  gap: 5px;
  overflow-x: auto;
  overflow-y: hidden;
  scrollbar-width: none;
  padding-bottom: 2px;
}
.g-react-scroll::-webkit-scrollbar { display: none; }
.g-react-btn {
  flex: 0 0 auto;
  display: grid;
  place-items: center;
  width: 32px;
  height: 32px;
  min-height: 32px;
  border-radius: 9px;
  background: rgba(167, 139, 250, 0.09);
  border: 1px solid rgba(167, 139, 250, 0.16);
  font-size: 15px;
  line-height: 1;
  transition: background var(--dur-fast), transform var(--dur-med) var(--spring);
}
@media (hover: hover) {
  .g-react-btn:hover:not(:disabled) { background: rgba(167, 139, 250, 0.22); transform: translateY(-2px); }
}
.g-react-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.g-react-door {
  flex: 0 0 auto;
  margin-left: auto;
  display: grid;
  place-items: center;
  height: 32px;
  min-height: 32px;
  padding: 0 12px;
  border-radius: 9px;
  background: rgba(167, 139, 250, 0.07);
  border: 1px solid rgba(167, 139, 250, 0.16);
  font-size: 10px;
  font-weight: 700;
  color: rgba(236, 231, 250, 0.5);
  white-space: nowrap;
}
.g-react-door.has-new { color: var(--gold); border-color: rgba(241, 193, 82, 0.4); }
/* Scene, not window (the durak.css conversion carries the full argument):
   density questions about the match column are questions about the box it
   sits in, and an authed narrow-scene desktop is phone-shaped here too. */
@container scene (max-width: 520px) {
  .fighter .fighter-caps piece { width: 14px; height: 14px; margin-right: -5px; }
  .emote-grid { grid-template-columns: repeat(5, 40px); }
  .emote-choice { width: 40px; height: 40px; }
  /* The `.is-over` board cap that used to live here — `min(52vh, 300px)` —
     is gone with the rest of the vh guesswork. The result panel is a row of
     the board column like any other, so the moment it mounts
     `fitMatchColumn()` takes its measured height out of `--board-cap` and the
     board gives back exactly what the panel needs, at any screen size. */
  .rp { gap: 10px; padding: 12px; }
  .rp-actions .rs-actions { gap: 8px; }
}

/* === reduced motion === */
@media (prefers-reduced-motion: reduce) {
  .board-frame.is-lowtime { animation: none; box-shadow: var(--shadow-card), var(--shadow-glow-danger); }
  .rs-frame { animation: none; }
  .btn.is-draw-offer { animation: none; }
  .g-emote { animation-duration: 0.001s; animation-fill-mode: forwards; opacity: 0; }
  .rp { animation: none; }
  /* The chip still has to distinguish four states without motion, so the
     blink and the pulse stop at their visible frame rather than at whatever
     the animation happened to leave behind. */
  .conn-pill.warn .conn-dot,
  .conn-pill.bad,
  .conn-pill.bad .conn-dot { animation: none; opacity: 1; }
}

/* === the chat door in the move-list header ===
   Portrait keeps the chat card in the column, so the door is hidden; it only
   exists in landscape, where the card has been lifted out. The emote door
   (`.moves-emote`, T13) follows the same law: portrait has the reaction strip
   and the popover button, so the header door only exists where those are
   deleted. */
.moves-chat,
.moves-emote { display: none; }
.moves-chat.has-new::after {
  content: "";
  position: absolute;
  top: 4px;
  right: 4px;
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--gold);
  box-shadow: 0 0 8px var(--gold);
}

/* === PHONE LANDSCAPE — the match screen only ===
   The measured fact this layout is built on (manuscript §2.11.2): rotating a
   phone makes the board SMALLER. 366px at 390x800 portrait, ~230px here. So
   landscape is not sold as a bigger board — it is sold as the move list and
   the chat door standing BESIDE the board, neither of which exists in a
   portrait match at all.

   Every other screen keeps its vertical scroll on rotation: their content is
   lists of cards and a second layout buys nothing.

   SCOPED TO `.land-match`, AND THAT IS THE WHOLE POINT OF THE CLASS. This
   block used to key on `.view-game`, which is not the chess match — it is the
   shared root class of NINE views (game, ai, replay, battleship, checkers,
   nardy, durak, arcomage, typing_race), so a chessboard grid with named
   `board / opp / moves / me / cd / pre / act / emo` areas was laid over a
   durak table, an arcomage tableau and a typing race. Measured at 844x390,
   authed, with the view pinned to the scene's 250px:

     arcomage    .board-frame 657.9px      durak       .board-frame 324px
     battleship  .board-frame 434.8px      typing_race .board-frame 465.9px,
                                                       first row at y = -24

   — and in all four the resign button was underneath the board's own content
   (`elementFromPoint` on «Сдаться» returned `.tc-mid`, `.dk-suit`, `.tr-text`,
   `.bs-boards`), i.e. unclickable, on the one screen where a player cannot
   simply leave. Nardy stacked `.nd-pips-row` and the player's own fighter card
   in the same grid area. Quiz and poker were fine for exactly one reason: they
   are the two match views that do NOT carry `.view-game`, so they kept the
   vertical scroll this block's own header promises everything else.

   `.land-match` is minted by the two views the layout was drawn for — the
   chess match and the local bot game, both a 8x8 board with a move list beside
   it (views/game.js, views/ai_game.js). Everything else rotates into its own
   `@container scene (max-width: 619px)` one-column layout and scrolls.

   Two things that are easy to miss and were both in the brief:
   1. 30px horizontal insets. In landscape the notch and the home indicator
      move to the SHORT sides, i.e. left and right, so the usual 12-16px
      gutters put content under them.
   2. The reactions row is gone — at 390px of height it would push the board
      off the screen — but the chat is NOT: its door moved into the move
      list's header (`.moves-chat` above).

   Threshold is a media query and not an @container: orientation is a property
   of the viewport, and no container query can ask about it. */
@media (orientation: landscape) and (max-height: 500px) {
  .view-game.land-match {
    /* Reclaim .container's padding (18px top / 16px sides / 24px bottom,
       base.css + shell.css, both frozen) and re-spend it as the landscape
       gutter. At 390px of height there is no 42px to donate.
       A PLAIN 30, not `max(30px, env(safe-area-inset-*))` any more: the shell
       itself now insets the whole grid by the horizontal safe area (shell.css
       §safe area), and `env()` is viewport-relative, so asking for it again
       inside the scene charged a notched phone twice — 47 from the frame and
       47 again here, 94px a side off a 844px screen. 30 is the design's own
       gutter and is what is left to state. */
    margin: -18px -16px -24px;
    padding: 6px 30px;

    /* --land-chrome is what the shell has already taken off the top before
       the scene starts: the hud plus the 28px marquee row. --land-below is
       everything under the board inside this view — the actions row, the
       status line, the six-pixel gaps and the view's own padding — measured,
       not guessed (see the 844x390 numbers in the lane report).
       The height has to be stated: without it the moves row's `1fr` resolves
       against max-content, the view grows to 562px inside a 304px scene and
       the whole landscape layout becomes a vertical scroll again. */
    /* `--match-h` is the scene's MEASURED height (ui/board.js), and it
       replaces the arithmetic here rather than adding to it: the guess below
       reconstructs the chrome from constants, and at 844x390 it came out
       54px too tall — the shell's action row is not in the sum, and the
       `+56px` companion query only fires under 800px of width. The view then
       overflowed the scene and the buttons were simply gone. The calc stays
       as the fallback for a browser without ResizeObserver. */
    --land-chrome: calc(var(--hud-h, 58px) + 28px);
    /* 82, not 74, and the eight pixels are the action row's touch floor. The
       row is `--land-below`'s only variable part, so raising `.g-tools
       .icon-btn` and `.g-controls .btn` off 34/36px to 44 has to be paid for
       here or the view overflows the scene and the buttons vanish — which is
       exactly how this constant earned its comment the first time. Measured
       at 844x390: board 176 -> 168, still 8px above `MATCH_FIT_MIN_BOARD`. */
    --land-below: 82px;
    --land-space: var(--scene-h, calc(100dvh - var(--land-chrome)));
    --land-board: calc(var(--land-space) - var(--land-below));
    height: var(--land-space);

    /* The board column is sized in pixels, not `auto`: `auto` asks the track
       for the board's max-content width, the board asks the track back
       through `max-width: 100%`, and the circularity resolves to whatever
       min-content happens to be — measured at 143px where 228 fits. */
    grid-template-columns: calc(var(--land-board) + 8px) minmax(150px, 1fr);
    grid-template-rows: auto minmax(0, 1fr) auto auto auto auto auto;
    grid-template-areas:
      "board opp"
      "board moves"
      "board me"
      "cd    cd"
      "pre   pre"
      "act   act"
      "emo   emo";
    gap: 4px 10px;
    align-items: stretch;
  }

  /* The two columns dissolve so their children can be placed against the one
     grid: the fighter slots live in the board column in the DOM and have to
     stand in the right-hand one here. `display: contents` moves the boxes
     without moving a single node — no DOM churn on rotation. */
  .land-match > .g-board-col,
  .land-match > .g-side-col { display: contents; }

  /* The column's flex rules are inert under `display: contents`, but two of
     them are not: the compact block's `margin-top: auto` on the action row
     would shove it out of its grid area, and the board frame is a grid item
     here, not a flex one. */
  .land-match > .g-board-col > .board-frame { grid-area: board; align-self: start; padding: 4px; flex: 0 0 auto; }
  .land-match .g-actions-row { margin-top: 0; }
  .land-match .board-stage { max-width: min(var(--land-board), 100%); }
  /* topSlot is always first in the DOM and always the far side of the board;
     bottomSlot is always the player's own. layoutFighters() swaps the CARDS
     between them, never the slots. */
  .land-match > .g-board-col > .fighter-slot:nth-of-type(1) { grid-area: opp; }
  .land-match > .g-board-col > .fighter-slot:nth-of-type(2) { grid-area: me; }
  .land-match > .g-side-col > .moves-card { grid-area: moves; min-height: 0; overflow: hidden; display: flex; flex-direction: column; }
  .land-match > .g-board-col > .board-countdown { grid-area: cd; min-height: 0; }
  .land-match > .g-board-col > .premove-bar { grid-area: pre; }
  .land-match > .g-board-col > .g-actions-row { grid-area: act; }
  .land-match > .g-board-col > .emote-wrap { grid-area: emo; }

  /* Everything that does not fit beside a board on a 390px-tall screen. The
     reactions card is the deliberate deletion; the status card, the fair-play
     card and the finished-tools row are all reachable again the moment the
     phone is turned back.
     `.anon-card` used to need a line of its own here, as a child of the board
     column. It is a sidebar card now (views/game.js says why), so the
     `.g-side-col > .glass-card:not(...)` clause below already covers it —
     there is no `.g-board-col > .anon-card` left in any of the seven views
     that mint one, and a selector for one would be dead. */
  .land-match > .g-board-col > .finished-tools,
  .land-match > .g-side-col > .glass-card:not(.moves-card):not(.chat-card),
  .land-match > .g-side-col > .emote-card { display: none; }
  /* The same deletion, extended to the six views that do NOT opt into
     `land-match`. For a guest, `renderCtx()` (ui/shell.js) removes the inner
     `[data-shell="ctx-shell"]` slot; the `#ctx.shell-ctx` node itself stays,
     goes `:empty`, and shell.css collapses it to `display: none`, handing its
     grid track back to the scene — so the guest's scene is ~250px wider than
     the player's at the same window. At 844x390 that puts the guest's scene at
     688px — above the `@container scene (max-width: 619px)` one-column
     threshold — while the player's 436px scene stays below it. The guest
     therefore gets the two-column grid and a sidebar rail that is 187px
     tall; the player gets one column and the sidebar below the board with
     room to spare. The sign-in prompt is 198.1px — it covers the 187px rail
     entirely, leaving the guest staring at a clipped login card and nothing
     else until they scroll. Chess already deletes it here through the
     `land-match` clause above; this extends the deletion to the other six.
     The prompt is reachable again the moment the phone is turned back to
     portrait, where the sidebar is below the board and has the full width. */
  .view-game > .g-side-col > .anon-card { display: none; }
  /* The reaction strip is the portrait foot, and 390px of height has no room
     for it — the same deletion the manuscript already made for the reactions
     card. The tools row (the emote popover plus the four utilities) leaves
     the landscape foot for the same reason as the phone foot: the mock's
     landscape foot is only the two game buttons, full width, so the toggles
     that used to sit beside them go, and the emote button that the strip's
     absence had pushed back into that row goes with it. */
  .view-game.land-match .g-react-row { display: none; }
  .view-game.land-match .g-tools { display: none; }

  /* Everything in the right-hand column is paying for the move list, which is
     the only reason to turn the phone at all. Measured at 844x390: the card's
     own 18px padding and a 30px avatar left the list 4.8px — a header and
     nothing under it. */
  .land-match .fighter { padding: 5px 8px; gap: 7px; }
  .land-match .fighter-avatar { width: 24px; height: 24px; border-radius: 7px; font-size: 12px; }
  .land-match .fighter-name { font-size: 12.5px; }
  .land-match .fighter-clock { font-size: 15px; padding: 2px 8px; }
  .land-match .fighter-sub { min-height: 0; }
  .land-match > .g-side-col > .moves-card { padding: 8px 10px; }
  .land-match .moves-card .card-title { margin-bottom: 4px; font-size: 12px; }
  .land-match .moves-list { max-height: none; flex: 1 1 auto; min-height: 0; }
  .land-match .hist-btn { min-width: 28px; height: 26px; }
  /* A wrapped action row is not a cosmetic problem here: it is 44 extra
     pixels taken off the board, because --land-below assumes one line.
     `nowrap` on `.g-actions-row` as well as on `.g-controls`, because the wrap
     that actually happened was BETWEEN them — the slot and the tools are the
     two flex children of the row, and nothing was stopping the second from
     dropping under the first. Measured on /game and /ai at 844x390 with the
     notch inset taking the scene to 376px: the row needed 378, went to two
     lines of 44, and the board — whose height is fixed by `--land-board` and
     does not know the row grew — printed 32px over the resign button.
     `min-width: 0` on both, or `nowrap` only relocates the overflow: a flex
     ITEM that is itself a flex container floors at its content's min-content
     unless it is told otherwise, so the buttons' own `min-width: 0` never got
     a chance to matter. Measured on /ai at 844x390 with the notch inset: the
     row was 376 wide and still wanted 379, and the three pixels put a tool
     button on top of «Новая партия». */
  .land-match .g-controls { gap: 6px; flex-wrap: nowrap; min-width: 0; }
  .land-match .g-actions-row { flex-wrap: nowrap; min-width: 0; }
  /* The mock's landscape foot is the two game buttons at full width — the
     utilities row that used to sit beside them is gone (the .g-tools
     display:none above), so the buttons take the whole foot exactly as the
     portrait foot does. 44, not 36: «Сдаться» and «½ Ничья» are the two
     controls a landscape match is built around, and they were the only
     buttons in the arena rendered under the touch floor on both axes at
     once. The row is one line, so the eight pixels come out of the board
     through `--land-below` above, not out of the fold. `1 1 0`, not `0 0
     auto`: the two buttons share the foot evenly and the LABEL is what
     truncates when a translation runs long, never the layout. */
  .land-match .g-controls .btn {
    white-space: nowrap;
    flex: 1 1 0;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    min-height: 44px;
    padding: 8px 12px;
  }
  .land-match .g-controls-slot { flex: 1 1 auto; min-width: 0; }
  .land-match .moves-chat,
  .land-match .moves-emote { display: inline-flex; align-items: center; justify-content: center; position: relative; }
  /* RB4b — two deletions the concurrent RB4 pass did not make: the emote
     popover wrap (its button left with the tools row above, so the panel and
     its hint have no door in landscape) and the move list's history transport
     arrows (the mock's landscape list scrolls rather than paging the board;
     the chat door stays, and the arrows return on rotation). Both are
     transient, like the reactions / status / fair-play cards this block
     already deletes. */
  .view-game.land-match .emote-wrap { display: none; }
  .land-match .moves-card .hist-btn:not(.moves-chat):not(.moves-emote) { display: none; }

  /* T13 — the reactions' landscape door pays out here: `is-land-emotes` lays
     the portrait strip (`.g-react-row`) over the opponent's card in the
     right-hand column — a slim one-line overlay that never touches the board
     and sits under the chat overlay (12) and the result sheet (14). The strip
     is the portrait foot verbatim (same buttons, same handlers, same send
     gate), so anon viewers see the same disabled row they see upright. The
     opponent card goes visibility:hidden underneath, exactly as the chat
     overlay hides the slots it covers — the strip's own background is the
     only thing between ten emoji and a clock. */
  .view-game.land-match.is-land-emotes > .g-board-col > .g-react-row {
    display: flex;
    grid-row: opp-start / opp-end;
    grid-column: opp-start / opp-end;
    z-index: 11;
    align-self: stretch;
    align-items: center;
    min-width: 0;
    min-height: 0;
    padding: 4px 6px;
    border-radius: var(--r-md);
    border: 1px solid var(--stroke-strong);
    background: var(--panel-strong);
  }
  .view-game.land-match.is-land-emotes > .g-board-col > .fighter-slot:nth-of-type(1) { visibility: hidden; }

  /* The chat card is lifted out of the column and shown over the move list
     when its door is pressed — the two never share the ~150px the right-hand
     column has. */
  .land-match > .g-side-col > .chat-card { display: none; }
  .view-game.land-match.is-land-chat > .g-side-col > .chat-card {
    display: flex;
    flex-direction: column;
    /* Named areas mint `<name>-start` / `<name>-end` lines, which is how one
       item spans three of them without a fourth area. */
    grid-row: opp-start / me-end;
    grid-column: opp-start / opp-end;
    z-index: 12;
    min-height: 0;
    overflow: hidden;
  }
  .view-game.land-match.is-land-chat .chat-feed { flex: 1 1 auto; min-height: 0; }
  .view-game.land-match.is-land-chat > .g-board-col > .fighter-slot,
  .view-game.land-match.is-land-chat > .g-side-col > .moves-card { visibility: hidden; }

  /* Post-game there is no room for a panel beside a board that is already
     231px of a 304px scene, so the result becomes the sheet the manuscript
     draws for the phone (§2.11.1) — over the match, not beside it. */
  .land-match > .g-board-col > .rp {
    grid-row: board-start / act-end;
    grid-column: board-start / opp-end;
    z-index: 14;
    margin: 0;
    max-height: 100%;
    /* A sheet, not a pane: .glass-card's translucent fill would leave the
       move list legible straight through the outcome. */
    background: var(--panel-strong);
    -webkit-backdrop-filter: none;
    backdrop-filter: none;
  }
  .land-match .rp-outcome { font-size: 22px; }
  .land-match .rp-body { max-height: none; }
}

/* Below 800px the shell also shows the tab bar, so the scene is 56px shorter
   and the board has to give that back. Same layout, one constant. */
@media (orientation: landscape) and (max-height: 500px) and (max-width: 799px) {
  .view-game.land-match { --land-chrome: calc(var(--hud-h, 58px) + 28px + 56px + env(safe-area-inset-bottom)); }
}

/* === result panel (ui/result-panel.js) ===
   The passive rematch state used to be a compact bar hanging off the splash
   (`.is-mini`, live UX report 2026-07-24). It is now the action row of this
   panel — same buttons, moved — so the whole end of the game is one surface
   instead of an overlay balanced on top of a grey line.

   The three tones are the mock's, and they are NOT the existing --ok /
   --danger / --violet: those are chip colours picked against a chip, and at
   28px of headline they read as neon. Declared locally on purpose.  */
/* promote to tokens.css */
.rp {
  --rp-win: #35c783;
  --rp-loss: #ff8a94;
  --rp-draw: #c4b5fd;
  --rp-tone: var(--text);
  --rp-tone-soft: rgba(236, 231, 250, 0.14);

  display: flex;
  flex-direction: column;
  min-height: 0;
  gap: 12px;
  border-color: var(--rp-tone-soft);
  animation: popIn var(--dur-med) var(--spring) both;
}
.rp[data-tone="win"] { --rp-tone: var(--rp-win); --rp-tone-soft: rgba(53, 199, 131, 0.3); }
.rp[data-tone="loss"] { --rp-tone: var(--rp-loss); --rp-tone-soft: rgba(255, 138, 148, 0.3); }
.rp[data-tone="draw"] { --rp-tone: var(--rp-draw); --rp-tone-soft: rgba(196, 181, 253, 0.3); }

.rp-head {
  display: flex;
  align-items: center;
  gap: 10px;
  min-width: 0;
}
.rp-headline { min-width: 0; flex: 1 1 auto; }
.rp-outcome {
  font-family: var(--font-display);
  font-size: clamp(20px, 6vw, 28px);
  font-weight: 800;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  line-height: 1.1;
  color: var(--rp-tone);
}
.rp-term {
  margin-top: 3px;
  font-size: 13px;
  font-weight: 700;
  color: var(--text-dim);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.rp-score {
  flex: 0 0 auto;
  font-family: var(--font-mono, var(--font-display));
  font-size: 15px;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  color: var(--text-faint);
}
.rp-delta {
  flex: 0 0 auto;
  padding: 5px 11px;
  border-radius: var(--r-pill);
  border: 1px solid var(--rp-tone-soft);
  font-family: var(--font-mono, var(--font-display));
  font-size: 15px;
  font-weight: 800;
  font-variant-numeric: tabular-nums;
  color: var(--text-dim);
}
.rp-delta.is-up { color: var(--rp-win); }
.rp-delta.is-down { color: var(--rp-loss); }

/* The panel absorbs its own overflow: the shell is 100dvh and does not
   scroll, so a panel that grows past the space it has must scroll inside
   itself rather than push the board off the top of the screen. Today the body
   is empty and this costs nothing; it is here so that the round which fills
   the four slots does not have to re-derive it. */
.rp-body {
  display: flex;
  flex-direction: column;
  gap: 12px;
  min-height: 0;
  max-height: 46vh;
  overflow-y: auto;
  overscroll-behavior: contain;
}
.rp-body:empty { display: none; }

/* The four reserved slots. Each collapses while the server sends nothing for
   it — see the header of ui/result-panel.js for exactly which field each one
   is waiting on. */
.rp-stats {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 8px;
}
.rp-insight { font-size: 13px; line-height: 1.5; color: var(--text-dim); }
.rp-ach { display: flex; flex-direction: column; gap: 8px; }
.rp-opp { display: flex; align-items: center; gap: 10px; }
.rp-stats:empty,
.rp-insight:empty,
.rp-ach:empty,
.rp-opp:empty { display: none; }

.rp-actions:empty { display: none; }
/* deadends-map C1: the no-rematch exit row (window lapsed / spectator). In
   phone-landscape the panel is the whole screen, so these two buttons are the
   screen's only doors. */
.rp-actions .rp-exit-row { display: flex; gap: 8px; }
.rp-actions .rp-exit-row .btn { flex: 1 1 0; }
.rp-actions .rs-actions {
  margin-top: 0;
  grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr);
  align-items: stretch;
}
.rp-actions .rs-yes,
.rp-actions .rs-no { width: 100%; }

/* === connection chip: the two states the pill never had ===
   `.conn-pill` and its ok/bad tones live in components.css, which is frozen
   this round, so the amber and the dropped states are declared here with the
   pill's own selectors. Nothing else on the page carries these classes. */
/* promote to components.css */
.conn-pill.warn {
  color: var(--gold);
  background: rgba(var(--t-gold), 0.1);
  border-color: rgba(var(--t-gold), 0.3);
  font-variant-numeric: tabular-nums;
}
.conn-pill.warn .conn-dot {
  background: var(--gold);
  box-shadow: 0 0 10px var(--gold);
  animation: connBlink 1s steps(1, end) infinite;
}
/* Amber blinks, red pulses: two different alarms need two different rhythms
   or the only thing separating them is a colour, and one of the two people
   this ships to is red-green colourblind. */
.conn-pill.bad { animation: connPulse 1.1s ease-in-out infinite; }
.conn-pill.bad .conn-dot { animation: connPulse 1.1s ease-in-out infinite; }
@keyframes connBlink {
  0%, 49% { opacity: 1; }
  50%, 100% { opacity: 0.15; }
}
@keyframes connPulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.45; }
}
