/* Modern Claudefare — all UI chrome.
 *
 * Three rules run through everything here:
 *
 *   1. THE HUD IS NOT DECORATION. Every element on screen during a match either
 *      tells the player something they act on within the next second, or it is
 *      off. Modern Warfare's HUD is quiet at the edges and empty in the middle,
 *      and the middle is empty on purpose — that is where you are looking.
 *   2. NOTHING IN THE HUD ACCEPTS POINTER EVENTS except the two buttons on the
 *      end-of-match board. A stray hover target over a live canvas eats clicks
 *      that were meant to be shots.
 *   3. NO ELEMENT WEARS AN ACCENT BAR. See the note below — this is the single
 *      most expensive thing that was wrong with this file.
 *
 * ---------------------------------------------------------------------------
 * WHY THERE ARE NO `border-left` ACCENTS IN THIS FILE ANY MORE
 * ---------------------------------------------------------------------------
 * There used to be twelve. The same 3px accent bar was stamped on buttons,
 * lobby rows, panels, killfeed rows, scoreboard team headers and toasts — six
 * element types with nothing structurally in common, all wearing one
 * decoration, on top of `1px solid var(--line)` boxes, which is the same
 * instinct twice.
 *
 * The user's read was "AI slop", and the diagnosis behind that is exact: a bar
 * applied everywhere is a rule used INSTEAD OF a hierarchy. Nothing was
 * emphasised because everything was. Deleting the bars is only half the fix;
 * the other half is that emphasis now comes from things that carry meaning:
 *
 *   - weight, scale and colour of TYPE (a scoreboard is almost entirely type)
 *   - a filled SURFACE for the one row/column that is "you" or "selected"
 *   - real column alignment with tabular figures, so digits line up
 *   - space, and different amounts of it, to group things
 *
 * The accent (`--gold`) now has exactly ONE job: the primary action, and the
 * thing currently selected. If you are about to paint gold on something that
 * is neither, it is the wrong colour. `--blue` / `--red` mean friendly /
 * hostile and nothing else. `--green` means ready/confirmed and nothing else.
 *
 * Rules (`--rule`) are permitted only where they are doing a TABLE's job:
 * separating columns of comparable data, or sitting under a column header.
 * Never as an outline around a panel, and never coloured.
 */

:root {
  /* --- surfaces: a real ramp, so a panel differs from the page and from a
   *     raised header inside itself. One flat colour plus 1px outlines is what
   *     forces you to reach for decorative borders in the first place. */
  --s0: #06080a;            /* page floor, loading screen */
  --s1: #0b0e10;            /* panel */
  --s2: #12171a;            /* raised: header bands, selected column */
  --s3: #1a2126;            /* hover */
  --s4: #232c32;            /* pressed / active */

  --ink:    #eef1f3;
  --ink-2:  #a7b0b6;        /* labels, secondary values — 7.4:1 on --s1 */
  --ink-3:  #78838a;        /* quiet meta — 4.6:1 on --s1 */
  --ink-4:  #4d565c;        /* structural only, never text you must read */

  --gold:  #e0a437;         /* PRIMARY ACTION + CURRENT SELECTION. Nothing else. */
  --gold-d: #a2741f;
  --blue:  #7db0ff;         /* friendly */
  --red:   #e4596c;         /* hostile, damage, danger */
  --green: #74d195;         /* ready, confirmed */

  --rule:        rgba(238, 241, 243, 0.085);   /* table column / row rules */
  --rule-strong: rgba(238, 241, 243, 0.17);    /* under a column header */

  --hud-shadow: 0 1px 2px rgba(0, 0, 0, 0.9), 0 0 10px rgba(0, 0, 0, 0.5);

  /* --- type. Two families on a real contrast axis: a condensed display face
   *     for identity and actions, a technical sans for every label, value and
   *     table cell. Small text in a display face is the other reason this UI
   *     read as generated — 11px Staatliches at 0.16em tracking is a texture,
   *     not a word. */
  --display: 'Staatliches', 'Oswald', 'Arial Narrow', system-ui, sans-serif;
  /* CROSS-PLATFORM ON PURPOSE.
   *
   * This stack used to lead with 'DIN Alternate' and fall back through 'Roboto
   * Condensed' to system-ui. Both of those are SYSTEM fonts — DIN Alternate on
   * macOS, Roboto Condensed on Android — and only Staatliches is actually
   * web-loaded here. So on Windows and Linux every label, every value and all
   * the tabular figures silently fell through to Segoe UI or DejaVu: the design
   * as reviewed existed only on the author's machine.
   *
   * There is no budget here to ship another woff2, so the honest fix is a stack
   * whose fallbacks are DELIBERATE rather than accidental, ending in a real
   * grotesque on every platform. Tabular figures survive the fallback — Segoe
   * UI, Roboto and DejaVu Sans all carry `tnum` — which is what the numeric
   * columns actually depend on. Shipping a condensed technical face as a subset
   * woff2 is the proper fix and is noted in docs/UI.md. */
  --ui: 'DIN Alternate', 'Roboto Condensed', 'Archivo Narrow', 'Segoe UI',
        'Helvetica Neue', Inter, Roboto, 'DejaVu Sans', system-ui, -apple-system, sans-serif;
  --mono: ui-monospace, 'SF Mono', Menlo, Consolas, 'DejaVu Sans Mono', monospace;
  /* NUMERALS GET THEIR OWN STACK.
   *
   * Staatliches is a display face and its "1" is a bare vertical bar: at a glance
   * the bot-count read as "II" and a spawn-in countdown of 1 read as a stray line.
   * A HUD number is not decoration — it is a quantity the player acts on — so every
   * numeral in the game comes from a technical sans with unambiguous digits and
   * tabular widths, which is also what Modern Warfare does. */
  --num: var(--ui);

  /* --- z-index scale. Semantic, so nobody has to invent a 9999. */
  --z-bg: 0;
  --z-hud: 10;
  /* Full-screen damage effects, INSIDE #hud's stacking context.
   *
   * `#dmg-vignette` and `#screen-flash` were written as `z-index: var(--z-fx, 40)`
   * and `--z-fx` was never declared, so both resolved to the fallback 40 — the
   * same value as --z-modal. Contained by #hud's own context they could not reach
   * a menu, but 40 put them above every sibling in the HUD, including the live
   * scoreboard and the end-of-match board. A red wash over the final results is
   * not damage feedback. Low, so DOM order puts the boards on top. */
  --z-fx: 12;
  --z-screen: 20;
  /* INSIDE a `.screen`'s own stacking context, not on the page scale.
   *
   * `--z-sheet-bar` is the sticky band that must stay above scrolling content —
   * `.sheet-foot` and `.legal`. `--z-screen-veil` is the transition sheet, and it
   * is written as a DERIVATION rather than as 3, because "above everything inside
   * a screen, including its sticky bars" is a relationship. A literal here is
   * exactly how `--z-fx` came to equal `--z-modal`. */
  --z-sheet-bar: 2;
  --z-screen-veil: calc(var(--z-sheet-bar) + 1);
  --z-modal: 40;
  --z-loading: 60;
  --z-toast: 70;
  --z-gate: 200;            /* the device gate outranks everything */

  /* --- motion. Exponential ease-out; no bounce anywhere. */
  --ease: cubic-bezier(0.16, 1, 0.3, 1);
  --fast: 120ms;
  --med: 200ms;

  /* --- how much of the ENTRANCE choreography plays.
   *
   * Two scales rather than one, because a stagger and a duration are different
   * things: shortening the stagger tightens the sequence, shortening the duration
   * makes each element arrive faster. Boot plays both at 1 — that ceremony is the
   * game starting up and it happens once. Coming BACK to a screen you already
   * saw replays it at a fraction, so BACK feels like a step rather than a reboot.
   * `js/ui-boot.js` puts `.booted` on <html> at the first screen change; see the
   * choreography section for the override. */
  --mc-stagger: 1;
  --mc-dur: 1;

  /* --- legacy aliases. Kept so any stray reference resolves rather than
   *     silently computing to nothing. Do not use these in new rules. */
  --bg: var(--s0);
  --bg-2: var(--s2);
  --ink-dim: var(--ink-2);
  --ink-faint: var(--ink-3);
  --accent: var(--gold);
  --accent-2: var(--blue);
  --danger: var(--red);
  --good: var(--green);
  --line: var(--rule);
  --font: var(--display);
}

@font-face {
  font-family: 'Staatliches';
  src: url('assets/fonts/staatliches-latin.woff2') format('woff2');
  font-display: swap;
  unicode-range: U+0000-00FF, U+0131, U+2000-206F, U+2074, U+20AC, U+2122, U+2212;
}

* { box-sizing: border-box; }

/* NO SYNTHETIC BOLD ON THE DISPLAY FACE.
 *
 * Only ONE Staatliches face is loaded and its weight is `normal`. Every h1-h4 in
 * this document therefore inherited the UA's default `bold` and the browser
 * SMEARED the outline to fake it — including the 118px brand wordmark, the
 * largest type in the game. Faux-bold on a display face is instantly visible as
 * cheapness once you know to look, and it was on the first thing a player sees.
 *
 * Weight is pinned to 400 wherever the display face is used. Emphasis on this
 * face comes from size and colour; it has no second weight to reach for. */
h1, h2, h3, h4 { font-weight: 400; }
.brand h1, .sheet-head h2, .modal-card h2, .panel-label,
#scoreboard h3, #endboard h2, #load-label, .dg-mark, #device-gate h1 { font-weight: 400; }

html, body {
  margin: 0; padding: 0; height: 100%;
  background: var(--s0);
  color: var(--ink);
  font-family: var(--ui);
  font-size: 16px;
  overflow: hidden;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

.hidden { display: none !important; }

/* NO GENERIC `.vh` VISUALLY-HIDDEN UTILITY HERE.
 *
 * There was one, and nothing in the markup or in any module ever carried the
 * class — while `.comp-legacy` fifteen hundred lines below independently
 * reimplemented the identical four declarations for the one element that needs
 * them. An unused accessibility utility provides no accessibility; it provides a
 * second way to do a thing, which is how two spellings of one idea get shipped.
 * If a second element ever needs this, promote `.comp-legacy` into a shared rule
 * rather than adding a parallel one.
 */

#game-canvas {
  position: fixed; inset: 0;
  width: 100vw; height: 100vh;
  display: block;
  background: #000;
}

/* ============================== primitives ============================== */

/* A key cap. Real affordance for a real key, and it stops the controls legend
 * from being a wall of bolded words. */
kbd {
  font-family: var(--ui);
  font-size: 0.78em; line-height: 1;
  letter-spacing: 0.06em;
  display: inline-block;
  min-width: 1.55em; padding: 0.28em 0.42em;
  text-align: center;
  color: var(--ink);
  background: var(--s3);
  box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.5), 0 1px 0 rgba(255, 255, 255, 0.06);
  border-radius: 2px;
  vertical-align: 0.06em;
}

/* A section heading.
 *
 * THE EYEBROW PROBLEM, and why this no longer looks like one.
 *
 * This started as 11px at 0.2em tracking in --ink-3 over a 1px hairline, and it
 * appeared nine-plus times across the front end: DEPLOYMENT, IDENTITY & AIM,
 * VIDEO, AUDIO, MATCH, INVITE, CALL SIGN, MATCH SETTINGS, SQUAD — and then every
 * field label used a near-identical 11px/0.16em/--ink-3 treatment underneath it.
 *
 * One module stamped on every block is the tiny-tracked-uppercase-eyebrow tell,
 * and worse, it made the section heading and the field label look like the SAME
 * THING, so the hierarchy it was supposed to create didn't exist. A critic
 * reading the build called it texture rather than structure, which is exactly
 * right.
 *
 * The fix is difference in KIND, not in degree: a section heading is now set in
 * the display face at a readable size in near-full ink, and a field label stays
 * in the technical sans, small and tight. You can tell them apart at a glance
 * without reading either, and neither needs a rule under it to be found. */
/* `.screen .panel-label`, not `.panel-label`.
 *
 * These are `<h3 class="panel-label">` inside a `.screen`, and `.screen h3` sets
 * `color: var(--ink-3)` at specificity (0,1,1) — which beats a bare `.panel-label`
 * at (0,1,0). So the whole point of this rule, that a section heading is in near
 * full ink and a field label is not, was being silently overridden and the
 * headings still rendered the same grey as the labels beneath them. The eyebrow
 * fix was in the file and not on the screen. */
.screen .panel-label,
.panel-label {
  font-family: var(--display);
  font-size: 18px; font-weight: 400;
  letter-spacing: 0.045em;
  text-transform: uppercase;
  color: var(--ink);
  margin: 0 0 16px;
}

/* ============================== buttons ==============================
 * Three ranks and they look like three ranks:
 *   .btn.primary — a filled gold slab. There is one per screen.
 *   .btn         — a filled neutral slab.
 *   .btn.ghost   — type only, no box at all, until you hover it.
 * No borders, no gradients, no left bars.
 */
.btn {
  appearance: none;
  font-family: var(--display);
  font-size: 17px;
  line-height: 1;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: var(--ink);
  background: var(--s2);
  border: 0;
  padding: 14px 22px;
  cursor: pointer;
  text-align: left;
  position: relative;
  transition: background var(--fast) ease, color var(--fast) ease, transform 90ms var(--ease);
}
.btn:hover { background: var(--s3); }
.btn:active { background: var(--s4); transform: translateY(1px); }
.btn:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
.btn:disabled { color: var(--ink-3); cursor: default; background: var(--s1); }
.btn:disabled:hover { background: var(--s1); }

/* THE primary action. Filled, dark ink on gold — unmistakable at a glance and
 * it needs no bar to say so. */
.btn.primary {
  background: var(--gold);
  color: #14100a;
  font-size: 21px;
  padding: 17px 26px;
  letter-spacing: 0.08em;
}
.btn.primary:hover { background: #efb349; }
.btn.primary:active { background: var(--gold-d); color: #100d07; }

.btn.ghost {
  background: transparent;
  color: var(--ink-2);
  padding-left: 0; padding-right: 14px;
}
.btn.ghost:hover { background: transparent; color: var(--ink); }
.btn.ghost:active { background: transparent; transform: translateY(1px); }

/* .big survives from the old markup; it now only means "a bit larger", because
 * rank is carried by .primary. */
.btn.big { font-size: 20px; padding: 16px 24px; }
.btn.small { font-size: 13px; padding: 9px 14px; letter-spacing: 0.12em; }

/* ============================== screens ============================== */
.screen {
  position: fixed; inset: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
  z-index: var(--z-screen);
  background: var(--s0);
}
/* Every screen except the menu (which owns its own plate) gets a quiet floor —
 * a single soft light from above, not a radial gradient pretending to be one. */
#classes, #options, #lobby {
  background:
    linear-gradient(180deg, rgba(255, 255, 255, 0.035) 0%, transparent 22%),
    var(--s0);
}

/* The standard sub-screen shell: a title band pinned to the top-left of a
 * content column, content below it, actions at the bottom. LEFT-ALIGNED to a
 * grid — a centred column of boxes is the layout the user was reacting to. */
/* `align-content: start`, and no `1fr` row.
 *
 * With `grid-template-rows: auto 1fr auto` the body row absorbed all the spare
 * height, so on a 1000px-tall window the loadout table sat at the top, BACK sat
 * at the very bottom, and 450px of nothing separated them. Content flows from
 * the top and the actions follow it. */
.sheet {
  min-height: 100%;
  display: grid;
  grid-template-rows: auto auto auto;
  align-content: start;
  gap: clamp(18px, 3vh, 34px);
  width: min(1320px, 100%);
  margin: 0 auto;
  padding: clamp(22px, 4.5vh, 54px) clamp(20px, 4vw, 64px) clamp(28px, 5vh, 56px);
}
.sheet-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; flex-wrap: wrap; }
.sheet-head h2 {
  margin: 0;
  font-family: var(--display);
  font-size: clamp(30px, 3.6vw, 50px);
  line-height: 0.92;
  letter-spacing: 0.06em;
  text-transform: uppercase;
}
.sheet-head .sub {
  margin: 0;
  max-width: 62ch;
  font-size: 13px; line-height: 1.55;
  color: var(--ink-2);
  letter-spacing: 0.01em;
}
/* STICKY, for the same reason the attribution bar is.
 *
 * This is the THIRD time a band added to the loadout pushed its primary action off
 * screen: first the sheet's `1fr` body row put 450px between the table and BACK, then
 * the camo row landed below the fold, and fixing that pushed BACK to 956px in a 900px
 * viewport. Shaving pixels each time is losing a race — the screen gains a band per
 * session, so the action row must be reachable BY CONSTRUCTION rather than by
 * arithmetic that has to be redone.
 *
 * Pinned to the bottom of the scrollport with a scrim so content reads as passing
 * underneath it. `.sheet-body` carries matching bottom padding so the last band can
 * still be scrolled fully clear of the bar. */
.sheet-foot {
  display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
  position: sticky;
  bottom: 0;
  z-index: var(--z-sheet-bar);
  margin: 0 calc(-1 * clamp(20px, 4vw, 64px));
  padding: 14px clamp(20px, 4vw, 64px);
  background: linear-gradient(180deg, transparent, rgba(6, 8, 10, 0.92) 38%);
  backdrop-filter: blur(3px);
}
.sheet-body { padding-bottom: clamp(8px, 1.4vh, 18px); }
.sheet-body { min-width: 0; }

/* A destructive action pushed to the far end of the action row, away from the exit.
   `margin-left: auto` rather than a fixed offset, so it stays right-aligned whatever
   else the row gains — the loadout sheet has gained a band per session three times and
   arithmetic that has to be redone is how that race is lost. */
.sheet-foot .right { margin-left: auto; }

/* ARMED — the confirm state of RESET TO DEFAULTS. Amber rather than red: this discards
   settings, it does not delete anything a player cannot get back by dragging a slider,
   and reserving red for genuinely irreversible acts keeps red meaning something.

   OUTLINE AND TEXT ONLY, NO FILL — and that is a constraint, not a preference. A first
   version added an accent background plus a hover fill, which took the accent-as-surface
   count from 13 to 16 against `validate-ui-slop`'s limit of 14. That check exists because
   the user called this UI out for AI-slop tells, and the bar does not move to accommodate
   the change that broke it. Losing the fill is also the better design: a filled amber
   button sitting in the same action row as the gold primary would read as a second call
   to action rather than as a warning. No size or weight change either — a button that
   grows when it arms shifts the row under the cursor and invites the very misclick the
   confirm step exists to prevent. */
.sheet-foot .btn.armed { border-color: var(--accent); color: var(--accent); }

.screen h3 {
  margin: 0 0 10px;
  font-family: var(--ui);
  font-size: 11px; font-weight: 600; letter-spacing: 0.2em;
  color: var(--ink-3);
  text-transform: uppercase;
}
.screen .sub { color: var(--ink-2); font-size: 13px; }

/* ============================== main menu ==============================
 * A left-anchored editorial grid, not a centred stack. Identity top-left,
 * actions under it, the match you are about to start in a dense panel to the
 * right, attribution in a real footer bar. The eye lands on the title, then
 * the one gold slab, then the settings — which is the order you use them in.
 */
#menu {
  display: grid;
  grid-template-rows: 1fr auto;
  padding: 0;
  background: var(--s0);
}
.menu-grid {
  display: grid;
  grid-template-columns: minmax(320px, 0.95fr) minmax(300px, 0.65fr);
  grid-template-areas: 'brand  setup' 'nav  setup';
  align-content: center;
  align-items: start;
  gap: clamp(26px, 4vh, 52px) clamp(40px, 7vw, 130px);
  width: min(1320px, 100%);
  margin: 0 auto;
  padding: clamp(28px, 5vh, 70px) clamp(22px, 5vw, 78px) clamp(16px, 3vh, 32px);
  /* NO `min-height: 0` here.
   *
   * `#menu` is `grid-template-rows: 1fr auto` (content, then the footer bar).
   * `min-height: 0` let this row shrink below its own content, so at 1024x700 —
   * where the layout has stacked and is taller than the viewport — the row
   * stayed viewport-height and the BOT SKILL select was clipped underneath the
   * attribution bar instead of the page scrolling. Left to `auto`, the row grows
   * past the viewport and `.screen`'s `overflow-y: auto` does its job. */
}

.brand { grid-area: brand; text-align: left; }
.brand h1 {
  margin: 0;
  font-family: var(--display);
  /* The cap matters more than the floor. Left uncapped this hit 210px on a
   * 2560px panel, which is shouting rather than designing; 120px is the size it
   * renders at on a 1440 laptop, where it was judged right. */
  font-size: clamp(52px, 8.2vw, 120px);
  line-height: 0.84;
  letter-spacing: 0.005em;
  text-transform: uppercase;
}
/* Two words, two weights of presence: the first is the surface, the second is
 * the subject. No glow — a text-shadow halo on a display face is the other
 * generated-looking move, and the plate behind it already gives separation. */
.brand h1 span { display: block; color: var(--ink); }
.brand h1 em {
  display: block; font-style: normal;
  color: var(--gold);
}
.tagline {
  margin: 14px 0 0;
  font-family: var(--ui);
  font-size: 12px; letter-spacing: 0.16em;
  /* `--ink-3` is the quiet-meta step, 4.6:1 against `--s1`. Over the live backdrop
   * it is not against `--s1` at all, and on Rust's white sky the subtitle vanished
   * completely. Up one step, plus the same halo the action list carries. */
  color: var(--ink-2);
  text-transform: uppercase;
  text-shadow: 0 1px 2px rgba(6, 8, 10, 0.92), 0 2px 12px rgba(6, 8, 10, 0.62);
}

/* --- links to /assets and /about, under the identity.
 *
 * Quieter than the action list on purpose: one step down in size, `--ink-3`
 * rather than `--ink`, and no surface on hover — just a rule that lights up.
 * They sit next to the wordmark, so anything with weight competes with the one
 * thing on this screen that is supposed to be loud.
 *
 * The same halo the tagline and the action list carry. These sit over a LIVE 3D
 * backdrop whose brightness is whatever the map is doing this second; on Rust's
 * white sky, quiet grey text against no shadow disappears entirely.
 *
 * `.brand` is in the selector because `.screen a` and `#menu p` rules elsewhere
 * in this file are more specific than a bare class would be, and a rule that
 * loses to an ancestor-and-tag selector is the exact failure
 * scripts/validate-ui-slop.mjs's specificity check exists to catch. */
.brand .menu-elsewhere {
  display: flex; flex-wrap: wrap; gap: 6px 20px;
  margin-top: 16px;
}
.brand .menu-elsewhere a {
  color: var(--ink-3);
  font-family: var(--ui);
  font-size: 11.5px; font-weight: 600; letter-spacing: 0.13em;
  text-transform: uppercase; text-decoration: none;
  padding: 6px 0;
  border-bottom: 1px solid transparent;
  text-shadow: 0 1px 2px rgba(6, 8, 10, 0.92), 0 2px 12px rgba(6, 8, 10, 0.62);
  transition: color var(--fast) ease, border-color var(--fast) ease;
}
.brand .menu-elsewhere a:hover,
.brand .menu-elsewhere a:focus-visible { color: var(--ink); border-bottom-color: var(--gold); }

/* --- the action list.
 * The primary is a slab. The rest are type on nothing, with a caret that slides
 * in on hover — a navigation affordance, which is what they are. */
.menu-main {
  grid-area: nav;
  display: flex; flex-direction: column;
  align-items: flex-start;
  gap: 2px;
  max-width: 420px;
}
.menu-main .btn.primary { width: 100%; margin-bottom: 14px; }
.menu-main .btn:not(.primary) {
  width: 100%;
  background: transparent;
  /* FULL INK, NOT THE SECONDARY RAMP.
   *
   * The ink ramp is calibrated against `--s1`, a dark SURFACE — `--ink-2` is 7.4:1
   * there and `--ink-3` is 4.6:1. But these five items do not sit on a surface. They
   * sit on a live 3D backdrop whose luminance is whatever the map is showing, and on
   * Rust that is a near-white sky. Against it, `--ink-2` measures close to nothing and
   * the user reported, correctly, that they could not read HOST A LOBBY / JOIN A LOBBY
   * / LOADOUT / OPTIONS / FEEDBACK at all.
   *
   * The scrim behind the column is real and strong, but a scrim cannot be the
   * guarantee: it is one fixed gradient over a scene that changes every frame and
   * every map. The requirement was "legible regardless of background", so the
   * legibility has to travel WITH the text — see the halo below. */
  color: var(--ink);
  padding: 11px 0 11px 0;
  font-size: 18px;
  letter-spacing: 0.1em;
  /* The halo, not a drop shadow. Two stops: a tight dark edge that keeps the letter
   * shapes crisp against a bright sky, and a wide soft one that lifts the whole word
   * off busy geometry. Both are near-black at the page's own background colour, so on
   * a dark frame they are invisible and cost nothing — this only shows up where it is
   * needed. Cheaper and more robust than a second plate behind the column, and it does
   * not add another card to a screen that already has the PLAY slab. */
  text-shadow:
    0 1px 2px rgba(6, 8, 10, 0.92),
    0 2px 14px rgba(6, 8, 10, 0.68);
}
/* NO HOVER CARET. This is where the accent bar came back.
 *
 * The first pass replaced the 3px left border with a 7px amber triangle at
 * `left: -18px`, revealed on hover. An adversarial critic put it in the defect
 * list, correctly: same colour, same left edge, one per button — the tell with
 * an opacity transition on it. It also sat in the gutter at x=114 while every
 * other element on the screen aligned to x=132, so the one hover affordance was
 * the one thing off the grid.
 *
 * Hover is now carried by the two things that cost nothing and mark nothing: the
 * label goes to full ink, and the row takes a surface. */
.menu-main .btn:not(.primary):hover,
.menu-main .btn:not(.primary):focus-visible {
  color: var(--ink);
  background: var(--s2);
}
.menu-main .btn:not(.primary) {
  padding-left: 12px; padding-right: 12px;
  margin-left: -12px;
  transition: background var(--fast) ease, color var(--fast) ease;
}
.menu-main .btn:not(.primary):active { transform: none; background: var(--s3); }

/* What PLAY is actually going to start. Mirrored from the four controls by
 * js/ui-boot.js, so the primary action states its own consequence. */
#menu-summary {
  margin: 0 0 16px;
  font-family: var(--ui);
  font-size: 11.5px; letter-spacing: 0.13em;
  /* Same correction as `.tagline`: this states what PLAY is about to start, so it is
   * not decorative meta — it has to be readable over whatever the map is showing. */
  color: var(--ink-2);
  text-transform: uppercase;
  text-shadow: 0 1px 2px rgba(6, 8, 10, 0.92), 0 2px 12px rgba(6, 8, 10, 0.62);
}
#menu-summary b { color: var(--ink); font-weight: 600; }

/* --- the deployment panel: dense label/value rows on a raised surface. */
.menu-setup {
  grid-area: setup;
  background: rgba(11, 14, 16, 0.82);
  padding: clamp(18px, 2.4vw, 26px) clamp(18px, 2.2vw, 26px) clamp(20px, 2.6vw, 28px);
  display: flex; flex-direction: column;
  min-width: 0;
  backdrop-filter: blur(3px);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.055);
}
/* The four fields. One column, because a label and its control belong on the
 * same line of sight and two columns of 150px halved the room for "Team
 * Deathmatch". The gap is what stops the next label sitting on the previous
 * input — a base rule, not only a breakpoint one. */
.menu-setup-grid { display: grid; grid-template-columns: minmax(0, 1fr); gap: 15px; }
.field-note { margin: 12px 0 0; font-size: 11.5px; line-height: 1.55; color: var(--ink-4); }
.field-note:empty { display: none; }

/* --- footer bar. The attribution is a requirement, so it gets a real place
 *     rather than 11px of grey floating over the artwork. */
/* STICKY, because the attribution is the one line on this page that is not
 * allowed to be unreachable.
 *
 * As a static footer in the menu's grid it sat at y=770 in a 700px-tall window —
 * present in the document and scrollable to, but off screen on arrival. For a
 * fan project the "Fan-made, non-commercial…" line is the thing that must always
 * be visible, so the bar pins to the bottom of the viewport and the content
 * scrolls behind it. */
.legal {
  position: sticky;
  bottom: 0;
  z-index: var(--z-sheet-bar);
  padding: 14px clamp(22px, 5vw, 78px) 16px;
  background: rgba(4, 6, 8, 0.72);
  backdrop-filter: blur(4px);
  display: grid;
  grid-template-columns: minmax(0, auto) minmax(0, 1fr);
  align-items: center;
  gap: 6px clamp(24px, 4vw, 60px);
}
.legal p { margin: 0; }
.legal .attribution {
  font-size: 11.5px; letter-spacing: 0.03em;
  color: var(--ink-2);
  font-weight: 600;
}
/* The author credit. Deliberately NOT an accent colour: the footer sits under the
 * whole menu and the accent is spent on the one thing that should pull the eye
 * there, which is PLAY. A rule using --gold here would also spend one of the
 * fourteen accent rules the menu is capped at, on a link nobody needs to find
 * first. So it reads as a link by being brighter than its sentence and underlined,
 * and lifts to full --ink on hover. */
.legal .attribution a.credit {
  color: var(--ink);
  text-decoration: underline;
  text-decoration-thickness: 1px;
  text-underline-offset: 2px;
  text-decoration-color: color-mix(in srgb, var(--ink) 40%, transparent);
  transition: text-decoration-color 140ms var(--ease, ease);
}
.legal .attribution a.credit:hover,
.legal .attribution a.credit:focus-visible {
  text-decoration-color: var(--ink);
}
.controls-legend {
  font-size: 11.5px; line-height: 2.1;
  color: var(--ink-3);
  letter-spacing: 0.02em;
  text-align: right;
  max-width: none; margin: 0;
}
.controls-legend .k { color: var(--ink-2); white-space: nowrap; }

/* ==================== menu choreography ====================
 * THE BAR, in the user's words: "a menu screen that if you opened up an Xbox or
 * PS5 and you loaded the menu screen of the latest Call of Duty or Battlefield 6".
 *
 * What actually separates a console front end from a web page is not effects. It is
 * TIMING AND RESTRAINT, in two layers:
 *
 *   1. A STAGGERED ENTRANCE. Elements arrive in the order you read them, 45 ms
 *      apart, each decelerating rather than stopping. The stagger is what makes a
 *      screen feel authored instead of painted — one simultaneous fade reads as a
 *      web page, six sequenced ones read as a sequence somebody chose.
 *   2. A CONTINUOUS IDLE LAYER. A console menu is never completely still, and this
 *      is the half that reads as expensive: the wordmark breathes a fraction of a
 *      percent, a specular sweep crosses it every twelve seconds, the grain jitters.
 *      All far below the threshold where you would call it animation — you notice
 *      only when it stops.
 *
 * WHY THESE ARE `animation` AND NOT `transition`, AND WHY NOTHING HAS
 * `animation-fill-mode: backwards`:
 *
 * A reveal must enhance an already-visible default. If the entrance were a
 * class-triggered transition, or filled backwards from `opacity: 0`, then any
 * environment that does not run animations — a background tab, a headless
 * renderer, a reduced-motion user, a screenshot harness — would render the menu
 * BLANK. The keyframes here start at 0 and end at the element's natural state with
 * no fill, so if the animation never runs the menu is simply already there. That is
 * a correctness property, not a nicety; this project has shipped a black frame
 * before.
 */
@media (prefers-reduced-motion: no-preference) {
  /* --- the entrance. Read order: identity, tagline, summary, primary, nav, panel.
   *
   * Every duration and delay is multiplied by `--mc-dur` / `--mc-stagger`, which
   * are 1 at boot and much smaller once `.booted` is up. The numbers below are
   * therefore the BOOT timings and the only ones anybody has to reason about;
   * the return is one override rather than a second copy of this table that has
   * to be kept in step. */
  .brand h1 span   { animation: mcRise calc(620ms * var(--mc-dur)) var(--ease) calc(40ms  * var(--mc-stagger)) 1; }
  .brand h1 em     { animation: mcRise calc(620ms * var(--mc-dur)) var(--ease) calc(105ms * var(--mc-stagger)) 1; }
  .tagline         { animation: mcFade calc(520ms * var(--mc-dur)) var(--ease) calc(210ms * var(--mc-stagger)) 1; }
  #menu-summary    { animation: mcFade calc(520ms * var(--mc-dur)) var(--ease) calc(300ms * var(--mc-stagger)) 1; }
  .menu-main .btn.primary { animation: mcSlab calc(560ms * var(--mc-dur)) var(--ease) calc(255ms * var(--mc-stagger)) 1; }
  /* The four secondary rows, 45 ms apart — the band that reads as deliberate
   * without becoming a queue you wait through. */
  .menu-main .btn:not(.primary):nth-of-type(2) { animation: mcRow calc(480ms * var(--mc-dur)) var(--ease) calc(345ms * var(--mc-stagger)) 1; }
  .menu-main .btn:not(.primary):nth-of-type(3) { animation: mcRow calc(480ms * var(--mc-dur)) var(--ease) calc(390ms * var(--mc-stagger)) 1; }
  .menu-main .btn:not(.primary):nth-of-type(4) { animation: mcRow calc(480ms * var(--mc-dur)) var(--ease) calc(435ms * var(--mc-stagger)) 1; }
  .menu-main .btn:not(.primary):nth-of-type(5) { animation: mcRow calc(480ms * var(--mc-dur)) var(--ease) calc(480ms * var(--mc-stagger)) 1; }
  .menu-setup      { animation: mcPanel calc(640ms * var(--mc-dur)) var(--ease) calc(300ms * var(--mc-stagger)) 1; }
  .legal           { animation: mcFade calc(600ms * var(--mc-dur)) var(--ease) calc(620ms * var(--mc-stagger)) 1; }

  /* --- the idle layer. Amplitudes are deliberately at the edge of perceptible. */
  /* The wordmark breathes: 0.4% of scale over nine seconds. */
  /* `mcTitleJitter` is defined in title-fx.css and attached HERE because `animation`
   * is a shorthand: declaring it in that file to add one name would silently drop the
   * breath, and copying the breath's timings into a second stylesheet is the drift this
   * project keeps paying for. 7.3s against 9s is coprime, so the two never re-align. */
  .brand h1 {
    animation: mcBreath 9s ease-in-out 1.4s infinite,
               mcTitleJitter 4.3s steps(1) 1.1s infinite;
    transform-origin: 0% 50%;
  }
  /* The slam rides the PARENT, so its `transform` cannot collide with the breath's
   * `scale` or the jitter's `translate` on the h1 — a parent transform composes with
   * a child's rather than replacing it. `.brand` carries no other animation, so this
   * is the one place in the wordmark where a whole-element transform is free. */
  .brand {
    animation: mcTitleSlam 6.1s steps(1) 3.4s infinite;
    transform-origin: 0% 50%;
  }
  /* A specular sweep across the gold word, as a light source passing a metal sign.
   * Masked to the text, so it lights the letters rather than drawing a band. */
  .brand h1 em {
    background-image: linear-gradient(100deg,
      transparent 38%, rgba(255, 255, 255, 0.42) 50%, transparent 62%);
    background-size: 280% 100%;
    background-repeat: no-repeat;
    background-position: -60% 0;
    -webkit-background-clip: text; background-clip: text;
    /* The fill stays the gold; the gradient only ADDS light on top of it, which is
     * why this is not the banned gradient-text move — remove the animation and the
     * word is still solid --gold. */
    -webkit-text-fill-color: var(--gold);
    /* This rule re-declares `animation`, so it OVERRIDES the entrance line above
     * for this element — the `mcRise` half has to be repeated here or the gold
     * word simply would not arrive. Both copies read from the same two scales, so
     * they cannot drift. The sweep is idle-layer and is deliberately NOT scaled:
     * it is a light passing a metal sign, not part of the entrance. */
    animation: mcRise calc(620ms * var(--mc-dur)) var(--ease) calc(105ms * var(--mc-stagger)) 1,
               mcSweep 12s linear 3s infinite;
  }
  /* The plate's grain jitters by a pixel, so the veil never looks like a static
   * overlay sitting on glass. */
  .menu-bg > span:nth-child(3) { animation: mcGrain 1.9s steps(3) infinite; }

  /* --- screen transitions. main.js toggles `.hidden` (display:none), and coming
   * back from display:none restarts a CSS animation — so a screen animates in
   * every time it is shown, with no JS and no state to keep. */
  #classes .sheet, #options .sheet, #lobby .sheet { animation: mcSheet 420ms var(--ease) 1; }
  .modal-card { animation: mcModal 320ms var(--ease) 1; }

  /* ---- THE CUT: the screen-to-screen transition itself -------------------
   *
   * The per-sheet entrance above animates the ARRIVING panel. What was missing is
   * the change of screen: `showScreen()` in js/main.js adds `.hidden` to one
   * section and removes it from another in the same tick, so a screen swap was a
   * hard cut with a panel sliding up inside it.
   *
   * WHY THIS IS ON THE INCOMING SCREEN AND NOT A CROSSFADE. Every `.screen` is
   * `position: fixed; inset: 0` with an opaque background and they all share
   * `--z-screen`, so paint order is DOM order and the arriving screen already
   * covers the leaving one completely — the leaving screen's exit is not visible
   * and animating it would be work nobody can see. What IS visible is the instant
   * at which the new screen appears, so that instant is what gets covered. It also
   * means this needs nothing from js/main.js, which is not ours: display:none ->
   * visible restarts these animations exactly as it does the sheets'.
   *
   * TWO LAYERS, MATCHED TO THE AUTHORED CUE. `assets/audio/ui_screen.mp3` is
   * 180 ms of band-limited noise sweeping DOWNWARD with no transient on the front
   * — the audio session's note is that a transition with a click on it sounds like
   * a click. So the motion carries no pop either, and it travels the same
   * direction the sound does:
   *
   *   ::before  a near-black veil over the whole frame, clearing in 300 ms. This
   *             is the layer that actually hides the cut.
   *   ::after   a soft light band crossing top to bottom, low alpha — the
   *             screen-scale version of the specular sweep on the wordmark.
   *
   * SAME DURATION AND SAME CURVE FOR BOTH, deliberately. They are not two effects
   * that happen to overlap: the frame darkens and a light passes down it as it
   * re-lights, which is one gesture in two materials. Giving the band its own
   * timing made it a second event arriving after the veil had already gone, which
   * is the definition of decoration.
   *
   * BOTH REST AT `opacity: 0` AND NEITHER FILLS BACKWARDS. If animations do not run
   * — reduced motion, a background tab, a headless capture — there is no veil and
   * no band, and the screen is simply already there. A transition that can leave a
   * sheet over the menu is worse than no transition; this project has shipped a
   * black frame before. `content` is declared only inside this media query, so a
   * reduced-motion user does not even get the boxes. */
  /* `:not(.modal)`, and it is not tidiness. `#join-modal` and `#pause` are
   * `.screen.modal` — a TRANSLUCENT overlay with a backdrop blur, whose whole job
   * is to show the thing behind it. An opaque veil over the pause screen would
   * flash the live match black every time somebody pressed ESC. They already have
   * their own entrance (`.modal-card` / `mcModal`), because a dialogue opening on
   * top of a screen is a different gesture from the screen changing. (There is a
   * mechanical reason too: `backdrop-filter` on `.modal` makes it a containing
   * block for `position: fixed` descendants, so these boxes would size to the
   * modal rather than to the viewport.) js/ui-boot.js skips the same set for the
   * cue, so the sound and the picture describe the same event. */
  .screen:not(.modal)::before,
  .screen:not(.modal)::after {
    content: '';
    position: fixed;
    pointer-events: none;
    z-index: var(--z-screen-veil);
    opacity: 0;
  }
  .screen:not(.modal)::before {
    inset: 0;
    background: var(--s0);
    animation: mcCut 300ms var(--ease) 1;
  }
  .screen:not(.modal)::after {
    left: 0; right: 0;
    top: -42vh; height: 42vh;
    background: linear-gradient(180deg,
      transparent 0%, rgba(255, 255, 255, 0.075) 55%, transparent 100%);
    animation: mcCutSweep 300ms var(--ease) 1;
  }

  /* --- the return is not the boot.
   *
   * Until the player leaves the menu for the first time, the entrance is the game
   * starting up and it gets the full 1.1 s. After that, `#menu` becoming visible
   * means somebody pressed BACK, and replaying a boot ceremony to answer a BACK
   * press is the single clearest tell that a front end is a web page — a console
   * re-enters a screen it has already shown. Same choreography, a third of the
   * stagger and half the travel time, so the whole thing lands inside the 300 ms
   * veil above and reads as one move.
   *
   * The class is on <html>, from js/ui-boot.js, set on the FIRST screen change
   * rather than after a timer — the only way to see the menu twice is to change
   * screen twice, so there is nothing to race. */
  .booted #menu { --mc-stagger: 0.28; --mc-dur: 0.55; }
}

  /* --- INSIDE the sheets, not just the sheets themselves.
   *
   * The loadout, options and lobby animated in as a whole slab while their contents
   * appeared fully formed inside them — which reads as a panel sliding in rather
   * than as a screen assembling. The stagger has to reach the rows for the screen
   * to feel authored the way the menu does.
   *
   * WHAT IS DELIBERATELY *NOT* STAGGERED: `.lobby-row`. js/main.js rebuilds the
   * whole roster on every ready-toggle, and `display:none` -> visible restarts a
   * descendant's animations — so animating rows would re-run the entrance for four
   * players every time one of them pressed READY. A list that twitches whenever
   * somebody else acts is worse than a list that simply updates. */
  #class-cards > .card:nth-child(1) { animation: mcCol 460ms var(--ease) 90ms  1; }
  #class-cards > .card:nth-child(2) { animation: mcCol 460ms var(--ease) 135ms 1; }
  #class-cards > .card:nth-child(3) { animation: mcCol 460ms var(--ease) 180ms 1; }
  #class-cards > .card:nth-child(4) { animation: mcCol 460ms var(--ease) 225ms 1; }
  #class-cards > .card:nth-child(5) { animation: mcCol 460ms var(--ease) 270ms 1; }
  /* The gunsmith arrives after the table it describes. It re-renders on a class
   * change, so this also marks the change — short, and a fade rather than travel. */
  .gs-slots  { animation: mcFade 420ms var(--ease) 300ms 1; }
  .gs-optic  { animation: mcFade 420ms var(--ease) 360ms 1; }

  /* Options: the three groups, left to right, after the title. */
  .opt-grid > .opt-group:nth-child(1) { animation: mcCol 440ms var(--ease) 100ms 1; }
  .opt-grid > .opt-group:nth-child(2) { animation: mcCol 440ms var(--ease) 150ms 1; }
  .opt-grid > .opt-group:nth-child(3) { animation: mcCol 440ms var(--ease) 200ms 1; }

  /* Lobby: the three rails, left to right, then the two composition teams inside
   * the first of them. Deliberately NOT one identical entrance on every rail —
   * the map hero leads because it is the thing a player arriving in a lobby looks
   * at first, and the kit rail lands last because it is the thing they act on. */
  .lobby-brief         { animation: mcCol 460ms var(--ease) 90ms  1; }
  .lobby-main          { animation: mcCol 460ms var(--ease) 145ms 1; }
  .lobby-kit           { animation: mcCol 460ms var(--ease) 200ms 1; }
  .lobby-map-name      { animation: mcRise 520ms var(--ease) 150ms 1; }
  .comp-team[data-side=friendly] { animation: mcFade 400ms var(--ease) 240ms 1; }
  .comp-team[data-side=hostile]  { animation: mcFade 400ms var(--ease) 285ms 1; }

  /* --- the optic, when you fit it.
   * gunsmith.js replaces the buttons' markup, so a `transition` on the fitted state
   * has nothing to transition FROM — the element is new. An animation on the fresh
   * element is the shape that works, and it is added only on a click-driven
   * re-render so switching class does not flash a confirmation nobody asked for. */
  .gs-sight.on.just-fitted { animation: mcFit 420ms var(--ease) 1; }

/* Keyframes are declared unconditionally: a rule that references a missing
 * animation name is simply inert, and keeping them outside the media query means
 * the two halves cannot drift apart. */
@keyframes mcRise { from { opacity: 0; transform: translateY(0.22em); } }
@keyframes mcFade { from { opacity: 0; transform: translateY(6px); } }
@keyframes mcRow  { from { opacity: 0; transform: translateX(-14px); } }
@keyframes mcSlab { from { opacity: 0; transform: translateY(10px) scaleX(0.985); } }
@keyframes mcPanel { from { opacity: 0; transform: translateY(14px); } }
@keyframes mcSheet { from { opacity: 0; transform: translateY(10px); } }
@keyframes mcModal { from { opacity: 0; transform: translateY(8px) scale(0.985); } }
@keyframes mcCol { from { opacity: 0; transform: translateY(12px); } }
@keyframes mcFit {
  /* A seat, not a bounce: it settles into place and the light passes across it once. */
  0%   { transform: scale(0.985); box-shadow: inset 0 0 0 0 rgba(255, 255, 255, 0); }
  40%  { box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.55); }
  100% { transform: scale(1); box-shadow: inset 0 0 0 0 rgba(255, 255, 255, 0); }
}
/* `scale:` and not `transform: scale()`. THREE animations run on `.brand h1` —
 * breath, jitter and slam — and CSS resolves same-property conflicts by taking the
 * LAST animation in the `animation` list, not by composing them. Every one of these
 * keyframe sets names its property at 0% and 100%, so while all three used
 * `transform` the last one won at every instant and the earlier ones rendered
 * nothing at all: the breath had been dead since the jitter was attached, silently,
 * because a 0.4% scale nobody can see missing looks exactly like a 0.4% scale.
 * `scale`, `translate` and `rotate` are INDEPENDENT properties that compose with
 * each other and with `transform`, so breath (scale) and jitter (translate) now
 * coexist, and the slam — which needs both axes at once — moved to `.brand`. */
@keyframes mcBreath { 50% { scale: 1.004; } }
/* The cut. `from` only, so the resting `opacity: 0` is the `to` — which is what
 * makes the veil disappear rather than persist if anything interrupts it. */
@keyframes mcCut { from { opacity: 0.94; } }
@keyframes mcCutSweep {
  /* Opacity is pinned at both ends so the band is fully visible for the whole
   * crossing and invisible the instant the animation is over. 142vh clears a
   * 42vh band completely off the bottom of a 100vh frame. */
  from { opacity: 1; transform: translate3d(0, 0, 0); }
  to   { opacity: 1; transform: translate3d(0, 142vh, 0); }
}
@keyframes mcSweep {
  /* Long dwell off-frame, so the sweep is an event rather than a loop. */
  0%, 62% { background-position: -60% 0; }
  86%, 100% { background-position: 160% 0; }
}
@keyframes mcGrain {
  0%   { transform: translate3d(0, 0, 0); }
  33%  { transform: translate3d(-1px, 1px, 0); }
  66%  { transform: translate3d(1px, -1px, 0); }
}

/* ============================== fields ==============================
 * Label above control, label in the technical sans at 11px, control at 15px.
 * A focus ring, not a colour change, because a gold outline on eight fields at
 * once is the accent doing decoration again.
 */
.field, .opt {
  display: grid;
  gap: 6px;
  min-width: 0;
}
/* AN AUTHOR `display` DEFEATS THE USER-AGENT `[hidden]` RULE, WHATEVER THE SPECIFICITY.
 *
 * The line above is that author declaration, so `field.hidden = true` on a `.field` draws
 * a fully visible control while every state in the DOM says it is hidden. That is the
 * exact fault AGENTS.md records against `.gsel-list { display: flex }` — a 282x139 map
 * picker painted over a live match with `list.hidden === true`, `ctl.open === false` and
 * every closer in the module firing correctly. The symptom of that class is *an audit that
 * finds nothing wrong*, because the JS is right and the display is right for an open panel.
 *
 * js/net/lobby-ui.js hides `#lobby-loadout-field` when a weapon preset is in force. This
 * is the rule that makes the attribute mean what it says, and `validate-lobby-ui` asserts
 * the RENDERED box rather than the attribute for the same reason. Written for `.field` and
 * `.opt` generally rather than for the one id: the next `hidden` on either would have
 * inherited the identical bug. */
.field[hidden], .opt[hidden] { display: none; }
/* Tracking down from 0.16em. Wide tracking on a small grey uppercase label is
 * the eyebrow tell, and eight of these in a column read as one texture. Tight
 * and quiet reads as a form. */
.field > span, .opt > span {
  font-family: var(--ui);
  font-size: 11.5px; font-weight: 600;
  letter-spacing: 0.05em;
  color: var(--ink-3);
  text-transform: uppercase;
  display: flex; align-items: baseline; justify-content: space-between; gap: 10px;
}
.field input, .field select,
.opt input[type=text], .opt select {
  font-family: var(--ui);
  font-size: 15px; letter-spacing: 0.02em;
  color: var(--ink);
  background: var(--s2);
  border: 0;
  border-bottom: 1px solid var(--rule-strong);
  padding: 10px 12px;
  width: 100%;
  min-width: 0;
  transition: background var(--fast) ease, border-color var(--fast) ease;
}
.field input:hover, .field select:hover,
.opt input[type=text]:hover, .opt select:hover { background: var(--s3); }
.field input:focus, .field select:focus,
.opt input[type=text]:focus, .opt select:focus {
  outline: none;
  background: var(--s3);
  border-bottom-color: var(--gold);
}
.field input:focus-visible, .field select:focus-visible,
.opt input[type=text]:focus-visible, .opt select:focus-visible {
  outline: 2px solid var(--blue); outline-offset: 1px;
}
.field input[readonly] { color: var(--ink-2); font-family: var(--mono); font-size: 12.5px; letter-spacing: 0; }
select { cursor: pointer; }

/* ==================== game-UI select ====================
 * The user: "make sure the select components on the main menu are like game UI
 * components not the default select".
 *
 * A native <select>'s open list is drawn by the OPERATING SYSTEM — OS font, OS
 * scrollbar, OS highlight — outside the page entirely, so styling the closed
 * control changes nothing about the part a player looks at. js/ui-select.js builds
 * a real listbox and keeps the native element as the store, visually hidden, so
 * every `sel.value` read and `change` listener in js/main.js keeps working.
 *
 * `.gsel-native` is NOT `display: none`. A `display: none` form control is
 * excluded from the accessibility tree and from label association, and it stops
 * being focusable — which would break the `<label>` wrapping it and take the
 * control's name away from a screen reader. Clipped to a pixel, it keeps both. */
.gsel { position: relative; }
.gsel-native {
  position: absolute;
  width: 1px; height: 1px;
  overflow: hidden; clip: rect(0 0 0 0);
  white-space: nowrap;
  opacity: 0;
  pointer-events: none;
}
.gsel-trigger {
  display: flex; align-items: center; justify-content: space-between; gap: 10px;
  width: 100%;
  font-family: var(--ui);
  font-size: 15px; letter-spacing: 0.02em;
  color: var(--ink);
  background: var(--s2);
  border: 0;
  border-bottom: 1px solid var(--rule-strong);
  padding: 10px 12px;
  text-align: left;
  cursor: pointer;
  transition: background var(--fast) var(--ease), border-color var(--fast) var(--ease);
}
.gsel-trigger:hover { background: var(--s3); }
.gsel-trigger:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
.gsel-value { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* A caret drawn in CSS rather than a glyph, so it cannot fall back to a different
 * font and cannot be selected as text. */
.gsel-caret {
  flex: none;
  width: 0; height: 0;
  border-left: 4px solid transparent;
  border-right: 4px solid transparent;
  border-top: 5px solid var(--ink-3);
  transition: transform var(--fast) var(--ease), border-top-color var(--fast) var(--ease);
}
.gsel-trigger:hover .gsel-caret { border-top-color: var(--ink); }
.gsel-open .gsel-trigger { background: var(--s3); border-bottom-color: var(--gold); }
.gsel-open .gsel-caret { transform: rotate(180deg); border-top-color: var(--gold); }

/* `position: fixed` on <body>, placed from the trigger's rect by JS.
 * `.field` and `.menu-setup` clip, `.screen` scrolls, and `.modal` makes a
 * containing block with `backdrop-filter` — an absolutely-positioned list inside
 * any of those is cut off. This escapes all three. */
.gsel-list {
  position: fixed;
  z-index: var(--z-gate);
  /* A FALLBACK, NOT THE RULE. js/ui-select.js sets `max-height` inline from the room
   * actually available beside the trigger — the hardcoded 280px here was chosen when the
   * roster was six weapons and showed about 8 of 17, which is what the report
   * *"not realyl scrollable"* is describing. This value only applies if the JS has not
   * placed the panel yet. */
  max-height: 62vh;
  display: flex;
  flex-direction: column;
  overscroll-behavior: contain;
  background: var(--s1);
  /* A BORDER OR A SHADOW, NOT BOTH. The first version paired a 1px border with a
   * 24px soft drop shadow, which is the ghost-card pattern `validate-ui-slop`
   * refuses — and it caught it. A floating list does need to separate from what is
   * behind it, but this UI's language is hairline rules on flat surfaces, so the
   * border is the idiom and the shadow is trimmed to a tight one that reads as
   * elevation rather than as glow. */
  border: 1px solid var(--rule-strong);
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
  padding: 4px;
}
/* `hidden` HAS TO WIN, AND BY DEFAULT IT DOES NOT. THIS IS THE WHOLE OF THE REPORTED
 * *"another bug where this map selection just stayed persistent as i kept playing?"*
 *
 * `js/ui-select.js` closes the panel by setting `hidden` on it, which is the right
 * mechanism — it takes the panel out of the accessibility tree as well as out of the
 * picture. But the rule that acts on that attribute lives in the USER-AGENT stylesheet,
 * and every author declaration beats every user-agent one whatever the specificity. The
 * `display` above is an author declaration on the same element, so it won, and the panel
 * was NEVER HIDDEN BY ANYTHING. Measured in Chrome against this stylesheet: a bare
 * `<div hidden>` computes `display: none`, the same div carrying this class computes
 * `display: flex` — a 282x139 panel still painted at `--z-gate`, over the match, over the
 * pause card, with the module correctly believing it had closed it.
 *
 * That is why the symptom reads as "nothing closes it": the closers all ran. A control
 * that reports its state through one attribute and draws itself through another is a
 * control whose state and picture can disagree silently, so the guard is asserted on what
 * the panel RENDERS (`scripts/validate-select-dismiss.mjs`), not on the attribute.
 *
 * Kept beside the block it corrects rather than as a global `[hidden] { ... !important }`:
 * the fault is this rule's, the repair belongs with it, and an `!important` sweep over the
 * whole document would change every other component that has ever relied on the cascade. */
.gsel-list[hidden] { display: none; }
/* THE SCROLLER IS THE OPTION LIST, NOT THE PANEL. The search field has to stay put
 * while the options move under it — a filter box that scrolls off the top is a filter
 * box you cannot clear without scrolling back. So the panel is a flex column, this is
 * the part that scrolls, and `.gsel-search` is the fixed header above it. */
.gsel-opts { overflow-y: auto; overscroll-behavior: contain; flex: 1 1 auto; min-height: 0; }
/* Reported: *"choosing primary gun from dropdoiwn neds to be searchable"*. Shown only
 * once the list is long enough to need it — see SEARCH_FROM in js/ui-select.js. */
.gsel-search {
  flex: 0 0 auto;
  margin: 2px 2px 6px;
  padding: 8px 10px;
  font-family: var(--ui);
  font-size: 13px; letter-spacing: 0.02em;
  color: var(--ink);
  background: var(--s2);
  border: 1px solid var(--rule);
  border-radius: 0;
  outline: none;
  -webkit-appearance: none;
  appearance: none;
}
.gsel-search::placeholder { color: var(--ink-3); }
.gsel-search:focus { border-color: var(--gold); }
/* An empty result must SAY it is empty. A blank panel reads as a broken control, which
 * is the complaint this whole control is being rebuilt against. */
.gsel-empty {
  padding: 14px 12px;
  font-family: var(--ui);
  font-size: 12px; letter-spacing: 0.02em;
  color: var(--ink-3);
  text-align: center;
}
.gsel-opt {
  font-family: var(--ui);
  font-size: 14px; letter-spacing: 0.02em;
  color: var(--ink-2);
  padding: 8px 10px;
  cursor: pointer;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* `.active` is the KEYBOARD position and `.on` is the committed value. They are
 * different facts and a listbox that conflates them cannot show you where you are
 * while arrowing past the current selection. */
.gsel-opt.active { background: var(--s3); color: var(--ink); }
.gsel-opt.on { color: var(--gold); }
.gsel-opt.on::after { content: '✓'; float: right; margin-left: 10px; }
.gsel-opt[aria-disabled=true] { color: var(--ink-3); cursor: default; }
/* An <optgroup> heading. Not an option — it takes no pointer and the keyboard
 * walks past it — so it is styled as the field label it is, matching `.ars-cat`
 * in the arsenal so one taxonomy reads the same on both screens. */
.gsel-group {
  padding: 10px 14px 4px;
  font-family: var(--ui);
  font-size: 10px; font-weight: 600; letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--ink-3);
  pointer-events: none;
}
.gsel-group:first-child { padding-top: 6px; }
@media (prefers-reduced-motion: no-preference) {
  .gsel-list { animation: mcSelIn 130ms var(--ease) 1; }
}
@keyframes mcSelIn { from { opacity: 0; transform: translateY(-4px); } }

/* The value readout beside a slider's label. This is where the accent earns its
 * keep on the options screen: one bright numeral per row instead of a gold bar. */
.opt i {
  color: var(--gold);
  font-style: normal;
  font-family: var(--num);
  font-variant-numeric: tabular-nums;
  font-size: 12px;
  letter-spacing: 0.04em;
}

/* --- range: a neutral hairline track with one bright handle.
 * Twelve `accent-color` sliders painted twelve full-width gold bars, which made
 * the options screen the loudest surface in the game for the least important
 * information on it. The FILL is neutral, the HANDLE is what you look for, and
 * `--fill` is kept in step by js/ui-boot.js so both engines agree. */
input[type=range] {
  -webkit-appearance: none; appearance: none;
  width: 100%; height: 20px;
  background: transparent;
  cursor: pointer;
  margin: 0;
}
input[type=range]::-webkit-slider-runnable-track {
  height: 2px;
  background: linear-gradient(90deg, var(--ink-4) var(--fill, 50%), var(--rule) var(--fill, 50%));
}
input[type=range]::-webkit-slider-thumb {
  -webkit-appearance: none;
  width: 9px; height: 16px; margin-top: -7px;
  background: var(--ink-2);
  border: 0; border-radius: 1px;
  transition: background var(--fast) ease, height var(--fast) ease;
}
/* HOVER BRIGHTENS; FOCUS TAKES THE ACCENT. They were one rule painting both gold,
 * which meant every slider under the pointer lit up as though it were the
 * selected control — and the options panel has eight of them. Hover is "the
 * pointer is here", which `--ink` says perfectly well; keyboard focus really is
 * the current control, and that is what the accent is for. */
input[type=range]:hover::-webkit-slider-thumb { background: var(--ink); }
input[type=range]:focus-visible::-webkit-slider-thumb { background: var(--gold); }
input[type=range]::-moz-range-track { height: 2px; background: var(--rule); }
input[type=range]::-moz-range-progress { height: 2px; background: var(--ink-4); }
input[type=range]::-moz-range-thumb {
  width: 9px; height: 16px; border: 0; border-radius: 1px;
  background: var(--ink-2);
}
/* The Firefox half of the same split. Both engines must agree or the same slider
 * means two different things depending on the browser. */
input[type=range]:hover::-moz-range-thumb { background: var(--ink); }
input[type=range]:focus-visible::-moz-range-thumb { background: var(--gold); }
input[type=range]:focus-visible { outline: 2px solid var(--blue); outline-offset: 3px; }

/* --- checkbox row: control first, label after, both on one baseline. */
.opt.row {
  grid-template-columns: auto 1fr;
  align-items: center;
  gap: 11px;
  padding: 7px 0;
}
.opt.row > span { letter-spacing: 0.13em; display: block; }
/* Styled, because `accent-color` alone drew the UNCHECKED state as a solid
 * bright white block — the highest-contrast object in its column, shouting for an
 * OFF setting. An off switch that looks louder than an on switch is backwards.
 *
 * Kept as a real <input type=checkbox>: only the box is drawn, so focus, the
 * label's click target, keyboard toggling and the accessibility tree are all
 * untouched. This is restyling a standard control, not reinventing one. */
input[type=checkbox] {
  appearance: none; -webkit-appearance: none;
  width: 17px; height: 17px;
  margin: 0;
  cursor: pointer;
  background: var(--s2);
  box-shadow: inset 0 0 0 1px var(--rule-strong);
  display: grid; place-content: center;
  transition: background var(--fast) ease, box-shadow var(--fast) ease;
}
input[type=checkbox]:hover { background: var(--s3); }
input[type=checkbox]::before {
  content: '';
  width: 10px; height: 10px;
  background: #14100a;
  clip-path: polygon(14% 46%, 0 62%, 40% 100%, 100% 22%, 86% 8%, 38% 70%);
  transform: scale(0);
  transition: transform var(--med) var(--ease);
}
input[type=checkbox]:checked { background: var(--gold); box-shadow: none; }
input[type=checkbox]:checked::before { transform: scale(1); }
input[type=checkbox]:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }

/* ============================== options ==============================
 * Grouped. Twelve controls in one undifferentiated auto-fit grid meant a text
 * field, four audio sliders, a quality dropdown and two checkboxes all had the
 * same rank, so finding sensitivity meant reading all twelve.
 */
/* Deterministic column counts, not `auto-fit`.
 *
 * `repeat(auto-fit, minmax(270px, 1fr))` fitted three columns to four groups, so
 * MATCH wrapped underneath IDENTITY & AIM as an orphan in column one while VIDEO
 * and AUDIO ended short. There are exactly four groups and they should break
 * 4 → 2 → 1, never 3. */
.opt-grid {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  align-items: start;
  gap: clamp(22px, 2.6vw, 44px);
  width: min(1040px, 100%);
}
.opt-group { display: flex; flex-direction: column; gap: 15px; min-width: 0; }
@media (max-width: 1000px) { .opt-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }

/* ============================== loadout ==============================
 * NOT A CARD GRID. Five identical bordered cards with a 3px top accent, four on
 * one row and an orphan on the next, is exactly the shape the user named.
 *
 * It is a COMPARISON TABLE — five columns of the same six measurements — so it
 * is built as one: no card chrome, hairline column rules doing a table's job,
 * the stat rows aligned across columns so you can compare by scanning
 * horizontally, and the selected column marked by a filled surface. The accent
 * appears only inside that one column.
 */
/* Pulled out by one column-padding on each side, so the first column's TEXT
 * sits on the same left edge as the "LOADOUT" heading instead of one padding
 * step inside it. Optical alignment to the page grid: the cell padding is
 * structure, and structure should not push the content off the grid. */
#class-cards {
  display: grid;
  grid-template-columns: repeat(5, minmax(0, 1fr));
  gap: 0;
  width: 100%;
  border-top: 1px solid var(--rule-strong);
}
/* Aligned to the page grid by ZEROING the outer padding of the end columns, not
 * by pulling the whole table out with a negative `margin-inline`.
 *
 * The negative margin did align the text, and it also made the grid wider than
 * its container, which left a horizontal overflow on `.sheet-body` at every
 * width — 1224px of content in a 1205px box at 1440, 719 in 707 at 768. A body
 * that scrolls sideways is a defect in its own right, and it was being paid for
 * an alignment that costs nothing done this way. */
#class-cards > .card:first-child { padding-left: 0; }
#class-cards > .card:last-child { padding-right: 0; }
.card {
  /* `color` MUST be set. A .card is a <button>, so the UA default `buttontext`
   * (black) wins over `body { color: … }` — measured contrast 1.161:1 for the class
   * names against the card, i.e. invisible. Inheriting is not automatic on a
   * form control. */
  color: var(--ink);
  font-family: var(--ui);
  text-align: left;
  appearance: none;
  border: 0;
  border-right: 1px solid var(--rule);
  background: transparent;
  padding: 18px clamp(12px, 1.3vw, 20px) 22px;
  cursor: pointer;
  position: relative;
  min-width: 0;
  display: grid;
  grid-template-rows: auto auto auto 1fr;
  align-content: start;
  transition: background var(--fast) ease;
}
.card:last-child { border-right: 0; }
.card:hover { background: var(--s1); }
.card:focus-visible { outline: 2px solid var(--blue); outline-offset: -2px; }
.card.sel { background: var(--s2); }
.card.sel::after {
  content: 'SELECTED';
  position: absolute; right: clamp(12px, 1.3vw, 20px); top: 20px;
  font-size: 9px; font-weight: 600; letter-spacing: 0.18em;
  color: var(--gold);
}
.card h4 {
  margin: 0 0 10px;
  font-family: var(--display);
  font-size: clamp(17px, 1.5vw, 22px);
  font-weight: 400;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: var(--ink);
}
.card.sel h4 { color: var(--gold); }
.card p { margin: 0; font-size: 12.5px; color: var(--ink-2); line-height: 1.5; }
/* Fixed so the weapon names and the bar block start on the same line in every
 * column. Without it the rows do not align and the comparison does not work. */
.card-role { min-height: 5.6em; }
.card-guns { margin-top: 0 !important; display: flex; flex-direction: column; gap: 3px; }
.card-guns b {
  color: var(--ink); font-weight: 600;
  font-size: 13.5px; letter-spacing: 0.06em; text-transform: uppercase;
}
.card-guns span { color: var(--ink-3); font-size: 11.5px; letter-spacing: 0.06em; text-transform: uppercase; }
/* Stat bars. Derived from the simulated numbers by js/loadouts.js, never authored
 * next to them — see the note there. One row per stat so a player compares two
 * classes by scanning ACROSS, which is the only comparison they make. */
.card-bars { display: flex; flex-direction: column; gap: 5px; margin-top: 16px; align-self: end; }
.cb { display: grid; grid-template-columns: 54px minmax(0, 1fr); align-items: center; gap: 8px; }
.cb-l {
  font-style: normal; font-size: 8.5px; font-weight: 600;
  letter-spacing: 0.1em; color: var(--ink-4);
  white-space: nowrap; overflow: hidden;
}
/* The track is darkened and the fill lifted, because the pair had to be legible
 * against EACH OTHER, not just against the panel. At --ink-4 on --rule the fill
 * was about 2.5:1 over its own track at 3px tall, which on the four unselected
 * columns meant the only data on the screen was grey on grey. */
.cb-t { display: block; height: 3px; background: rgba(0, 0, 0, 0.45); min-width: 0; }
/* Neutral everywhere, gold only in the selected column. This is the single
 * change that stopped the loadout screen being a wall of thirty gold bars. */
.cb-t b { display: block; height: 100%; background: var(--ink-3); transform-origin: left; transition: background var(--fast) ease; }
.card:hover .cb-t b { background: var(--ink-2); }
.card.sel .cb-t b { background: var(--gold); }
/* A ZERO-LENGTH BAR IS NOT FIXABLE FROM HERE, and it is a real defect:
 * js/loadouts.js normalises each stat across the arsenal, so the worst weapon on
 * a stat lands at exactly 0 and the best at exactly 1. MARKSMAN therefore shows
 * four completely empty rows (FIRE RATE, HANDLING, CONTROL, AMMO all 0%), which
 * a player reads as missing data rather than as "lowest", and M4A1 and MP5 both
 * peg FIRE RATE at 100% so they cannot be told apart.
 *
 * `min-width` cannot rescue it: js/loadouts.js sets the length as an inline
 * `transform: scaleX(v)`, and a transform scales a min-width to nothing just the
 * same. The fix belongs in the normalisation — floor the range at ~0.08 and cap
 * it below 1, or scale against the weapon class rather than the whole arsenal —
 * and js/loadouts.js is not this session's file. Raised in docs/UI.md. */

/* ==================== arsenal ====================
 * The weapon picker. There was no way to choose a gun at all: your primary was
 * whatever your class fixed it to.
 *
 * BUILT FOR FORTY-EIGHT, PROVEN AT SEVEN. The spec is roughly the top two of each
 * type from six Call of Duty titles, and designing against today's seven and
 * discovering it at forty-eight is the expensive order — this screen has already
 * pushed its own primary action off the bottom three times by growing a band at a
 * time. So the group count and the entry count are both free:
 *
 *   - `repeat(auto-fill, minmax(230px, 1fr))` — columns are decided by the width
 *     available, not by a breakpoint per group count. Five categories today, six
 *     or eight later, no rule changes.
 *   - Entries stack inside their own group, so a category with nine weapons grows
 *     DOWN its column rather than pushing the others sideways.
 *   - The whole band lives in `.sheet-body`, which scrolls under a `position:
 *     sticky` `.sheet-foot`. BACK stays reachable however tall this gets, which
 *     is the property that had to be structural rather than arithmetic.
 *
 * No `max-height` and no inner scroller. A nested scroll region inside a scrolling
 * sheet is two scrollbars competing for one wheel, and it hides exactly the
 * weapons a player is looking for.
 */
#arsenal {
  margin-top: clamp(22px, 3.4vh, 38px);
  padding-top: clamp(18px, 2.6vh, 28px);
  border-top: 1px solid var(--rule-strong);
}
.ars-groups {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
  gap: clamp(14px, 2vw, 26px);
  align-items: start;
}
.ars-group { min-width: 0; }
/* The category heading is a FIELD LABEL, not a section heading — it labels a
 * column of choices the way `.gs-slot dt` labels a value. Section headings are the
 * display face at 15px; these are the technical sans. Different in kind. */
.ars-cat {
  margin: 0 0 8px;
  font-family: var(--ui);
  font-size: 11px; font-weight: 600; letter-spacing: 0.16em;
  color: var(--ink-3); text-transform: uppercase;
}
.ars-item {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  gap: 2px 10px;
  width: 100%;
  margin: 0 0 2px;
  padding: 9px 11px;
  border: 0;
  background: transparent;
  color: var(--ink-2);
  text-align: left;
  cursor: pointer;
  font: inherit;
  transition: background var(--fast) var(--ease), color var(--fast) var(--ease);
}
.ars-name {
  font-family: var(--ui);
  font-size: 14px; font-weight: 600; letter-spacing: 0.02em;
  color: var(--ink);
  min-width: 0;
}
/* A quantity, so it is tabular and it is in the numeral stack — a column of times
 * a player is comparing down cannot jitter on the digit widths. */
.ars-ttk {
  font-family: var(--num);
  font-variant-numeric: tabular-nums;
  font-size: 12px;
  color: var(--ink-3);
  white-space: nowrap;
  align-self: baseline;
}
.ars-role {
  grid-column: 1 / -1;
  font-size: 11.5px; line-height: 1.45;
  color: var(--ink-3);
}
/* Hover is a surface and an ink change. No gutter caret: that was tried, and a
 * critic put it in the defect list as the accent bar by another route. */
.ars-item:hover { background: var(--s2); color: var(--ink); }
.ars-item:hover .ars-role { color: var(--ink-2); }
.ars-item:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
/* SELECTED IS THE FILLED SURFACE, the same answer the scoreboard's "this row is
 * you" and the class table's "this column is selected" already give. Gold is the
 * primary action and the current selection, and nothing else. */
.ars-item.on {
  background: var(--gold);
  color: #14100a;
}
.ars-item.on .ars-name { color: #14100a; }
.ars-item.on .ars-ttk,
.ars-item.on .ars-role { color: rgba(20, 16, 10, 0.78); }
.ars-item.on:hover { background: #efb349; }
.ars-item.just-fitted { animation: mcFit 420ms var(--ease) 1; }

/* ISSUED — the weapon this class ships with, so a player who has wandered off the
 * kit can find their way back without reading the note. Ink, not a surface: it is
 * a label on a row, and one more filled chip in a column of rows is noise. */
.ars-tag {
  margin-left: 8px;
  font-family: var(--ui);
  font-size: 8.5px; font-weight: 700; letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-3);
  font-style: normal;
}
.ars-item.on .ars-tag { color: rgba(20, 16, 10, 0.66); }

/* The restore control sits on its own line above the chips rather than inside the
 * head: the head is a heading and a search field, and a third thing in it collapses
 * at the width where the search box is already at its minimum. */
.ars-actions { margin: 10px 0 0; min-height: 0; }
.ars-actions button[hidden] { display: none; }

/* --- CATEGORY CHIPS.
 * A FILTER, not a selection of what you carry. The active chip is a neutral filled
 * surface, deliberately NOT `--gold`: this file's own rule is that the accent means
 * "primary action" and "currently selected" and nothing else, and a filter wearing
 * the same colour as the equipped weapon gives one colour two meanings on one
 * screen. `--ink` on `--s3` is the same "this one is active" the options tabs use. */
.ars-cats {
  display: flex; flex-wrap: wrap; gap: 6px;
  margin: 12px 0 clamp(12px, 1.8vh, 18px);
}
.ars-chip {
  appearance: none; border: 0;
  display: inline-flex; align-items: baseline; gap: 6px;
  padding: 6px 11px;
  background: transparent;
  color: var(--ink-3);
  cursor: pointer;
  font-family: var(--ui);
  font-size: 11px; font-weight: 600; letter-spacing: 0.12em;
  text-transform: uppercase;
  transition: background var(--fast) ease, color var(--fast) ease;
}
.ars-chip:hover { background: var(--s2); color: var(--ink-2); }
.ars-chip:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
.ars-chip.on { background: var(--s3); color: var(--ink); }
/* A count, so it is tabular — a row of chips a player reads across cannot jitter. */
.ars-chip i {
  font-family: var(--num); font-variant-numeric: tabular-nums;
  font-style: normal; font-size: 10.5px; font-weight: 600;
  color: var(--ink-3);
}
.ars-chip.on i { color: var(--ink-2); }

/* LOCKED BY A HOST PRESET. Not `disabled`: a disabled button swallows its own
 * click, and a player who cannot tell a locked control from a dead one reports the
 * dead one. The press is answered by a toast carrying the preset's own words. */
.ars-item[data-locked="1"],
.gs-eq-btn[data-locked="1"] { opacity: 0.42; cursor: not-allowed; }
.ars-item[data-locked="1"]:hover { background: transparent; }

/* ==================== gunsmith ====================
 * The three loadout slots the class table never showed, plus the optic. Sits
 * under the comparison table as a second band, so the table stays a table.
 */
#gunsmith {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr);
  gap: clamp(22px, 4vw, 56px);
  margin-top: clamp(22px, 3.4vh, 38px);
  padding-top: clamp(18px, 2.6vh, 28px);
  border-top: 1px solid var(--rule-strong);
  align-items: start;
}
/* The five slots as a definition list, because that is what they are: a label and
 * a value, five times, scannable down one column. */
#gs-slot-list { margin: 0; display: grid; gap: 0; }
.gs-slot {
  display: grid; grid-template-columns: 84px minmax(0, 1fr);
  gap: 12px; align-items: baseline;
  padding: 7px 0;
  border-bottom: 1px solid var(--rule);
}
.gs-slot:last-child { border-bottom: 0; }
.gs-slot dt {
  font-family: var(--ui);
  font-size: 11px; font-weight: 600; letter-spacing: 0.05em;
  color: var(--ink-3); text-transform: uppercase;
}
.gs-slot dd {
  margin: 0;
  font-family: var(--display);
  font-size: 16px; letter-spacing: 0.05em; text-transform: uppercase;
  color: var(--ink);
}

.gs-weapon { margin: 0 0 12px; display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
.gs-weapon b {
  font-family: var(--display); font-weight: 400;
  font-size: 20px; letter-spacing: 0.05em; text-transform: uppercase; color: var(--ink);
}
.gs-weapon span {
  font-family: var(--ui); font-size: 11px; font-weight: 600;
  letter-spacing: 0.05em; color: var(--ink-3); text-transform: uppercase;
}

/* Two options, so two buttons rather than a select. Each states its own ADS cost,
 * which is the whole basis for choosing between them. */
.gs-sights { display: grid; gap: 8px; }
.gs-sight {
  appearance: none; border: 0;
  display: flex; align-items: baseline; justify-content: space-between; gap: 14px;
  width: 100%; padding: 12px 14px;
  background: var(--s2);
  color: var(--ink-2);
  cursor: pointer;
  text-align: left;
  transition: background var(--fast) ease, color var(--fast) ease;
}
.gs-sight:hover { background: var(--s3); color: var(--ink); }
.gs-sight:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
/* Fitted. The accent means "currently selected" here exactly as it does on a
 * class column and a primary action — same meaning, same colour. */
.gs-sight.on { background: var(--gold); color: #14100a; }
.gs-sight.on:hover { background: #efb349; }
.gs-sight-name {
  font-family: var(--display);
  font-size: 15px; letter-spacing: 0.06em; text-transform: uppercase;
}
.gs-sight-ms {
  font-family: var(--num); font-variant-numeric: tabular-nums;
  font-size: 12.5px; font-weight: 600;
  flex: 0 0 auto;
}
.gs-sight:not(.on) .gs-sight-ms { color: var(--ink-3); }

/* --- camo. Its own row, because the finish is per PLAYER: pick gold once and every
 * gun is gold next match, so it does not belong inside a class column. */
/* IN THE RIGHT COLUMN, under the optic — not full-width across the bottom.
 *
 * At `grid-column: 1 / -1` it sat on its own row beneath both columns and pushed the
 * Finish row off the bottom of a 900px viewport: present in the DOM, correct, and
 * unreachable without scrolling. Meanwhile `.gs-slots` is five rows tall and
 * `.gs-optic` is two buttons and a line, so the right column had dead space exactly
 * the size of what was overflowing. Same content, no extra height. */
.gs-camo { grid-column: 2; margin-top: clamp(16px, 2.4vh, 26px); }
@media (max-width: 860px) { .gs-camo { grid-column: 1; } }
.gs-camo-row { display: flex; flex-wrap: wrap; gap: 8px; }
.gs-camo-btn {
  appearance: none; border: 0;
  display: flex; align-items: center; gap: 9px;
  padding: 9px 14px 9px 10px;
  background: var(--s2);
  color: var(--ink-2);
  cursor: pointer;
  transition: background var(--fast) ease, color var(--fast) ease;
}
.gs-camo-btn:hover { background: var(--s3); color: var(--ink); }
.gs-camo-btn:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
.gs-camo-btn.on { background: var(--gold); color: #14100a; }
.gs-camo-btn.on:hover { background: #efb349; }
.gs-camo-name {
  font-family: var(--display);
  font-size: 15px; letter-spacing: 0.06em; text-transform: uppercase;
}
/* The swatch approximates the finish rather than claiming to be it — the real thing
 * is a PBR material under a moving light and no flat square is going to stand in for
 * that. It is an identifier, so it only has to be recognisable at 16px. Values taken
 * from CAMO_PLATE in js/render/WeaponMaterials.js so the two do not drift far: gold
 * is a COLOURED specular (0xd9a533) rather than a bright one, which is why it reads
 * darker than steel in blue and green. */
.gs-camo-swatch {
  width: 16px; height: 16px; flex: 0 0 auto;
  background: linear-gradient(135deg, #6b7176, #3d4348);
  box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.4);
}
.gs-camo-swatch[data-camo=gold]    { background: linear-gradient(135deg, #f0c65c, #d9a533 46%, #8a6318); }
.gs-camo-swatch[data-camo=diamond] { background: linear-gradient(135deg, #ffffff, #dfe6f2 40%, #8fa2bd); }
.gs-camo-swatch[data-camo=pap]     { background: linear-gradient(135deg, #7be0ff, #4a7bd8 45%, #b64ad8); }
/* Which finishes move. `animated` comes from the CAMOS table, so a static finish
 * never claims to be live. */
.gs-camo-live {
  font-family: var(--ui);
  font-size: 8.5px; font-weight: 700; letter-spacing: 0.14em;
  padding: 2px 5px;
  color: var(--ink);
  background: rgba(255, 255, 255, 0.12);
}
.gs-camo-btn.on .gs-camo-live { color: #14100a; background: rgba(0, 0, 0, 0.16); }
.gs-camo-btn.just-fitted { animation: mcFit 420ms var(--ease) 1; }

/* --- EQUIPMENT. The lethal slot, in the LEFT column under the five-row slot list,
 * where `.gs-optic` + `.gs-camo` already fill the right one. Three buttons and a
 * line of copy is exactly the height the left column has spare, so this costs no
 * scroll — which matters, because `.sheet-foot` is sticky precisely BECAUSE this
 * screen has gained a band per session and twice pushed the action row off. */
.gs-equip { grid-column: 1; margin-top: clamp(16px, 2.4vh, 26px); }
@media (max-width: 860px) { .gs-equip { grid-column: 1; } }
/* No swatch to lead with, so the left padding matches the right. Otherwise it
 * inherits `.gs-camo-btn` wholesale — including the gold `.on` fill, which is the
 * correct meaning here: this IS the currently selected equipment. */
.gs-eq-btn { padding-left: 14px; }
/* The slot list's "you have swapped the class's rifle" marker. Same size and
 * weight as `.ars-tag` — it is the same fact stated in the other panel. */
.gs-swap {
  margin-left: 7px;
  font-family: var(--ui);
  font-size: 8.5px; font-weight: 700; letter-spacing: 0.14em;
  text-transform: uppercase; font-style: normal;
  color: var(--ink-3);
}

.gs-fixed { margin: 0; font-size: 13px; color: var(--ink-2); line-height: 1.55; }
.gs-fixed b { color: var(--ink); font-weight: 600; text-transform: uppercase; }
.gs-note { margin: 12px 0 0; font-size: 12px; line-height: 1.55; color: var(--ink-3); max-width: 52ch; }
.gs-note:empty { display: none; }

@media (max-width: 860px) {
  #gunsmith { grid-template-columns: minmax(0, 1fr); gap: 26px; }
}

/* ============================== lobby ==============================
 * THE WAITING SCREEN, HOST AND CLIENT.
 *
 * The previous version put a single 320px column against a 1280px frame with the
 * remaining four fifths empty, no picture of anything, and a roster whose ALPHA
 * tag was three hundred pixels wide. The user's word for it was "garbage", on
 * both sides.
 *
 * WHAT DRIVES THE LAYOUT: three questions, one rail each, over a live plate of
 * the map. DEPLOYMENT is what you are about to play, SQUAD is who is here, YOUR
 * KIT is what you are taking in. The rails are the capability model made
 * structural — everything in #lobby-settings is the host's, everything in
 * #lobby-kit is your own — so read-only is a property of WHERE a control lives
 * rather than of an exemption list somebody has to remember to extend.
 *
 * THE PLATE IS `position: fixed`, NOT `absolute`. menu-bg.css positions the
 * backdrop absolutely, which is right inside #menu and wrong inside a `.screen`
 * that scrolls: an absolutely positioned inset:0 child scrolls with the content
 * and slides off the top the moment the lobby is taller than the window. Fixed,
 * against a `position: fixed` .screen, is the same rectangle that cannot scroll.
 * It still paints below the sheet: an element's own background paints first, then
 * its negative-z-index descendants, then its in-flow content.
 */
/* THE LAYERING, AS A RELATIONSHIP.
 *
 * menu-bg.css puts the plate at a bare `z-index: -1` and its own comment says why
 * that works: a negative-z-index child paints above its parent's BACKGROUND and
 * below its content, "which works only because .screen is position:fixed with a
 * z-index and therefore forms a stacking context. Do not remove either."
 *
 * It does work — measured, at 769, 1280, 1600 and 1920, the plate is painted and
 * the screen's opaque `var(--s0)` floor is under it. But it is an invariant held
 * by two properties in a file this rule does not own, expressed as a literal, and
 * silent when broken: the failure mode is a flat colour where a map should be,
 * with every element present, loaded and at opacity 1. A reviewer measured
 * exactly that this session and reasonably concluded the plate was covered.
 *
 * So the lobby states it instead: the plate sits ON the screen's floor
 * (`--z-bg`), and the sheet sits one layer above the plate. Both are positioned,
 * so neither depends on `.screen` forming a stacking context, and the derivation
 * is encoded rather than described — `calc(var(--z-bg) + 1)`, never a number.
 *
 * `position: fixed` rather than menu-bg.css's `absolute`: `.screen` scrolls, and
 * an absolutely positioned inset:0 child scrolls with the content and slides off
 * the top the moment the lobby is taller than the window — which it is at every
 * width below 820. */
#lobby > .menu-bg { position: fixed; z-index: var(--z-bg); }
#lobby > .sheet { position: relative; z-index: calc(var(--z-bg) + 1); }

/* A scrim, so 15px type over a sunlit map is still 15px type you can read.
 *
 * TUNED AGAINST THE RAILS, NOT PICKED. The rails already sit at 0.72 alpha, so
 * anything they cover is scrimmed twice and the first version at 0.90/0.62/0.93
 * put the map at roughly 0.03 of its own brightness — a picture of the map that
 * was, in practice, black. The heavy bands are only where type sits DIRECTLY on
 * the plate: the title row at the top and the action row at the bottom. The
 * middle third is nearly clear, because that is the band the rails protect
 * themselves and it is the band the map is actually visible in. */
#lobby > .menu-bg::before {
  content: '';
  position: absolute; inset: 0;
  z-index: 2;
  background:
    linear-gradient(180deg, rgba(4, 6, 8, 0.70) 0%, rgba(4, 6, 8, 0.20) 24%,
                            rgba(4, 6, 8, 0.16) 64%, rgba(4, 6, 8, 0.84) 100%);
  pointer-events: none;
}

/* FILL THE FRAME, AND KEEP THE ACTION ROW REACHABLE BY CONSTRUCTION.
 *
 * `.sheet`'s default is `auto auto auto` + `align-content: start`, which leaves
 * the action row wherever the content happens to end — and this screen gained
 * three rails, a map hero, a gun picker and a swatch row in one session. The
 * loadout screen has been through that race three times and lost it three times,
 * including once with the fix for the previous loss.
 *
 * So the sheet is EXACTLY the viewport, the body row is `minmax(0, 1fr)` and
 * clips, and each rail scrolls inside itself. Whatever lands in a rail next
 * cannot move the foot, because the foot is not downstream of the rails' height.
 * `height: 100%` rather than `min-height`: with a minimum the body row still
 * resolves against its content and the sheet grows past the window, which is the
 * exact state that put the preset control and the voice button under the sticky
 * foot on the first render of this design. */
.lobby-sheet {
  height: 100%;
  grid-template-rows: auto minmax(0, 1fr) auto;
  align-content: stretch;
  gap: clamp(10px, 1.6vh, 20px);
}
.lobby-sheet > .sheet-body { min-height: 0; padding-bottom: 0; }
/* THE TITLE BAND SITS DIRECTLY ON THE PLATE, so it is the one place on this
 * screen where type has no surface under it. Everything in it is a shade lighter
 * than the same thing inside a rail and carries the HUD's own shadow — the status
 * line was --ink-3 on first render, which over Rust's bright sky measures about
 * 1.5:1 and is simply not readable. Contrast is a property of the pair, and the
 * background here is a photograph. */
.lobby-head { align-items: flex-end; gap: clamp(16px, 3vw, 40px); }
.lobby-head, .lobby-head h2, .lobby-head .lobby-code-tag,
.lobby-head .lobby-invite-label, .lobby-head .field > span {
  text-shadow: var(--hud-shadow);
}
.lobby-ident { min-width: 0; }
.lobby-ident h2 { margin-bottom: 6px; }
#lobby-note { margin: 0; font-size: 12.5px; line-height: 1.5; color: var(--ink-2); max-width: 58ch; }

/* INVITE. The code is read aloud, the link is pasted — two different acts, so
 * two different controls rather than one doing both badly. */
.lobby-invite { display: flex; align-items: flex-end; gap: clamp(14px, 2vw, 26px); flex-wrap: wrap; }
.lobby-code-block { display: flex; flex-direction: column; gap: 5px; flex: 0 0 auto; }
.lobby-invite-label {
  font-family: var(--ui);
  font-size: 9.5px; font-weight: 600; letter-spacing: 0.18em;
  color: var(--ink-2); text-transform: uppercase;
}
.lobby-code { display: flex; align-items: flex-end; gap: 10px; flex: 1 1 300px; min-width: 0; }
.lobby-code .field { flex: 1 1 auto; min-width: 0; }
.lobby-code input { font-family: var(--mono); font-size: 12px; text-overflow: ellipsis; }

/* ==================== the three rails ==================== */
.lobby-wrap {
  display: grid;
  grid-template-columns: minmax(238px, 300px) minmax(320px, 1fr) minmax(244px, 316px);
  gap: clamp(12px, 1.4vw, 24px);
  width: 100%; height: 100%;
  align-items: stretch;
  min-height: 0;
}
/* One surface treatment for all three, deliberately: they are peers, and a
 * different frame per rail would be decoration standing in for hierarchy.
 * Hierarchy comes from what is IN them — a 46px map name, a table, a swatch row.
 * The blur is not glassmorphism-as-default: there is a moving 3D render behind
 * this and the alternative is unreadable type. */
.lobby-rail {
  display: flex; flex-direction: column;
  gap: clamp(9px, 1.3vh, 14px);
  min-width: 0; min-height: 0;
  padding: clamp(12px, 1.7vh, 18px) clamp(13px, 1.1vw, 18px);
  border: 1px solid var(--rule);
  background: rgba(8, 11, 13, 0.72);
  backdrop-filter: blur(16px) saturate(1.05);
  /* EACH RAIL SCROLLS ITSELF. The alternative is the sheet growing past the
   * window, and then the sticky foot covers whatever is at the bottom of the
   * tallest rail — which on the first render of this design was the weapon preset
   * and the voice button, both invisible and both perfectly present in the DOM. */
  overflow-y: auto;
  overscroll-behavior: contain;
  scrollbar-width: thin;
  scrollbar-color: var(--ink-4) transparent;
}
@supports not (backdrop-filter: blur(2px)) {
  .lobby-rail { background: rgba(8, 11, 13, 0.94); }
}
.lobby-rail::-webkit-scrollbar { width: 8px; }
.lobby-rail::-webkit-scrollbar-thumb { background: rgba(238, 241, 243, 0.14); }
.lobby-rail::-webkit-scrollbar-thumb:hover { background: rgba(238, 241, 243, 0.24); }
.lobby-rail::-webkit-scrollbar-track { background: transparent; }
.lobby-rail > .panel-label { margin-top: 0; flex: 0 0 auto; }

/* ==================== rail 1 — deployment ==================== */
/* The map, said the way a game says it. This is the single largest thing the
 * screen was missing: a client could not see what map they were about to play. */
.lobby-map-hero { padding-bottom: clamp(10px, 1.4vh, 15px); border-bottom: 1px solid var(--rule-strong); }
.lobby-map-eyebrow {
  margin: 0 0 5px;
  font-family: var(--ui);
  font-size: 10px; font-weight: 600; letter-spacing: 0.16em;
  color: var(--ink-3); text-transform: uppercase;
}
/* `.screen .lobby-map-name`, NOT `.lobby-map-name`.
 *
 * This is an `<h3>` inside a `.screen`, and `.screen h3` sets an 11px --ink-3
 * eyebrow at specificity (0,1,1), which beats a bare class at (0,1,0). The first
 * version of this rule was written bare and the map name — the single largest
 * thing on the screen by design — rendered as an 11px grey label indistinguishable
 * from the field caption above it. The identical defeat is recorded against
 * `.panel-label` a hundred lines up in this file, and `validate-ui-slop`'s
 * specificity check caught this one on the first run. */
.screen .lobby-map-name {
  margin: 0;
  font-family: var(--display); font-weight: 400;
  font-size: clamp(28px, 2.9vw, 44px);
  line-height: 0.9; letter-spacing: 0.035em;
  text-transform: uppercase;
  color: var(--ink);
  text-wrap: balance;
  overflow-wrap: anywhere;
}
.lobby-map-mode {
  margin: 8px 0 0;
  font-family: var(--ui);
  font-size: 11.5px; font-weight: 600; letter-spacing: 0.1em;
  color: var(--ink-2); text-transform: uppercase;
}
.lobby-map-mode i { font-style: normal; color: var(--ink-4); padding: 0 6px; }
#lobby-settings { display: flex; flex-direction: column; gap: clamp(10px, 1.4vh, 14px); min-width: 0; }

/* ==================== rail 2 — squad ==================== */
.lobby-main { min-width: 0; }
.lobby-main-head { display: flex; align-items: baseline; justify-content: space-between; gap: 14px; flex-wrap: wrap; }
.lobby-main-head .panel-label { margin: 0; }

/* THE HUMAN CAP, AS PIPS. Four, because MAX_HUMANS is four — written by
 * js/lobby-composition.js from CFG, never from a literal here. The recorded
 * defect this replaces: "six pips over a twelve-slot match states a ceiling that
 * does not exist." */
#comp-humans {
  display: flex; align-items: center; gap: 8px;
  margin: 0;
  font-family: var(--num); font-variant-numeric: tabular-nums;
  font-size: 11px; font-weight: 600; letter-spacing: 0.1em;
  color: var(--ink-3); text-transform: uppercase;
}
#comp-humans b { color: var(--ink); font-weight: 700; }
#comp-humans.at-cap { color: var(--ink-2); }
.lobby-occ { display: flex; gap: 3px; }
.lobby-occ i {
  display: block; width: 9px; height: 9px;
  background: transparent;
  box-shadow: inset 0 0 0 1px var(--ink-4);
}
.lobby-occ i.taken { background: var(--ink-2); box-shadow: none; }
.lobby-occ i.self { background: var(--ink); box-shadow: none; }

#lobby-rows {
  display: flex; flex-direction: column;
  border-top: 1px solid var(--rule-strong);
  min-height: 132px;
}
/* A roster row. No border box, no left bar: a hairline under each row is the
 * table rule it always was, and READY is a state so it reads as a filled pill.
 *
 * EVERY CELL IS ASSIGNED A COLUMN BY NAME. The previous version declared six
 * columns and let the children fall into them in document order — so with voice
 * off the name landed in the `auto` column and the ALPHA tag landed in the `1fr`,
 * which is exactly the 300px-wide team box in the screenshot. Assigning by class
 * makes the layout independent of which optional cells are present. */
#lobby-rows .lobby-row {
  display: grid;
  grid-template-columns: 13px minmax(0, 1fr) auto auto auto auto;
  align-items: center; gap: 10px;
  padding: 11px 6px;
  border-bottom: 1px solid var(--rule);
  font-family: var(--ui);
  font-size: 14.5px; letter-spacing: 0.035em;
  color: var(--ink);
}
#lobby-rows .lobby-row .lobby-spk  { grid-column: 1; }
#lobby-rows .lobby-row .lobby-name { grid-column: 2; }
#lobby-rows .lobby-row .lr-side    { grid-column: 3; }
#lobby-rows .lobby-row .lr-class   { grid-column: 4; }
#lobby-rows .lobby-row .lobby-mute { grid-column: 5; }
#lobby-rows .lobby-row .lr-tag     { grid-column: 6; }
/* Your own row, marked by weight rather than by a coloured edge. */
#lobby-rows .lobby-row.is-self { background: rgba(255, 255, 255, 0.028); }
#lobby-rows .lobby-row.is-self .lobby-name { font-weight: 700; }
/* AN OPEN HUMAN SLOT. Occupancy as a list rather than only as a readout: a
 * two-player squad in a four-slot lobby is two names and two open rows, which is
 * how you can see at a glance that somebody is missing. It is also what stops the
 * roster being two rows floating at the top of an empty rail. */
#lobby-rows .lobby-row.is-open { color: var(--ink-4); }
#lobby-rows .lobby-row.is-open .lobby-name { font-weight: 400; letter-spacing: 0.14em; }
#lobby-rows .lobby-row.is-open .lr-tag { background: transparent; color: var(--ink-4); }
.lobby-row .lr-tag {
  font-family: var(--num); font-variant-numeric: tabular-nums;
  font-size: 9.5px; font-weight: 600; letter-spacing: 0.14em;
  color: var(--ink-3);
  padding: 4px 8px;
  background: var(--s2);
  white-space: nowrap;
}
.lobby-row.ready .lr-tag { color: #0d1a12; background: var(--green); }
.lobby-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-transform: uppercase; }
/* The disambiguating suffix on a duplicate call sign. Quieter than the name it
 * distinguishes, because it is a tie-breaker rather than part of the name. */
.lobby-name .lr-dup { color: var(--ink-3); font-weight: 600; }
.lobby-host-star { color: var(--ink-2); }

/* Voice activity. The dot is ALWAYS in the row so the column never collapses and
 * the roster does not reflow when the host turns voice on; `data-voice="off"`
 * makes it an empty gutter rather than a lit indicator. Driven at 5 Hz by the
 * meter loop, so the transition is what stops it reading as a strobe. */
.lobby-spk {
  color: #3a4048; font-size: 0.8em; line-height: 1;
  transition: color 0.12s linear, text-shadow 0.12s linear;
}
.lobby-spk[data-voice="off"] { visibility: hidden; }
.lobby-spk.on { color: var(--green); text-shadow: 0 0 8px rgba(116, 209, 149, 0.75); }

/* The side tag carries the team colour, so friend/enemy is legible before the
 * name is read. Same two tokens the roster rows and the HUD use. */
.lr-side {
  font-family: var(--ui);
  font-size: 9.5px; font-weight: 700; letter-spacing: 0.12em;
  padding: 3px 6px;
  border: 1px solid currentColor;
  white-space: nowrap;
}
.lr-side[data-team="friendly"] { color: var(--blue); }
.lr-side[data-team="hostile"]  { color: var(--red); }
.lr-class {
  font-family: var(--ui);
  font-size: 9.5px; letter-spacing: 0.08em; color: var(--ink-3); text-transform: uppercase;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 12ch;
}
.lobby-mute {
  font-family: var(--ui);
  font-size: 9px; font-weight: 600; letter-spacing: 0.1em;
  background: none; border: 1px solid var(--rule-strong); color: var(--ink-3);
  padding: 3px 6px; cursor: pointer;
  transition: border-color var(--fast) ease, color var(--fast) ease;
}
.lobby-mute:hover { border-color: var(--ink-3); color: var(--ink); }
.lobby-mute.muted { border-color: var(--red); color: var(--red); }
.lobby-mute:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }

/* The one-line answer to "what is this match", under the roster. */
.lobby-summary {
  margin: 12px 0 0;
  font-family: var(--ui);
  font-size: 11.5px; letter-spacing: 0.04em; line-height: 1.55;
  color: var(--ink-3);
}
.lobby-summary b { color: var(--ink); font-weight: 700; font-variant-numeric: tabular-nums; }

/* THE FEED. Notices stack and age out on their own; render() never clears them,
 * which is what stops "somebody left" being erased by the next lobby packet. */
.lobby-feed { list-style: none; margin: 10px 0 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
.lobby-feed:empty { display: none; }
.lobby-feed li {
  font-family: var(--ui);
  font-size: 11px; letter-spacing: 0.04em; line-height: 1.5;
  color: var(--ink-3);
  padding: 6px 9px;
  background: rgba(255, 255, 255, 0.035);
  animation: lobbyFeed 260ms var(--ease) 1;
}
.lobby-feed li b { color: var(--ink-2); font-weight: 700; text-transform: uppercase; }
.lobby-feed li[data-kind="leave"] b { color: var(--red); }
.lobby-feed li[data-kind="join"] b { color: var(--green); }
@keyframes lobbyFeed { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
@media (prefers-reduced-motion: reduce) {
  .lobby-feed li { animation: none; }
}

.lobby-controls { margin-top: auto; padding-top: 12px; }
.lobby-row-controls { display: flex; align-items: center; gap: 12px; margin: 6px 0; flex-wrap: wrap; }
.lobby-row-controls .sub { margin: 0; font-size: 11.5px; color: var(--ink-3); flex: 1 1 200px; min-width: 0; }

/* ==================== rail 3 — your kit ==================== */
.lobby-kit .gs-camo-row { margin-top: 2px; }
.lobby-kit .gs-note { margin-top: 6px; max-width: none; }
.lobby-camo { margin-top: 2px; }
.lobby-camo .panel-label { margin-bottom: 8px; }
/* The lethal slot, beside the finish and built from the same row component — one
 * visual vocabulary across the lobby and the solo loadout screen rather than two
 * pickers that look nearly the same. */
.lobby-equip { margin-top: 2px; }
.lobby-equip .panel-label { margin-bottom: 8px; }
.lobby-kit .gs-camo-row { margin-top: 2px; }

/* The preset banner. Quiet when nothing is restricted, loud when something is —
 * a restriction nobody can see is indistinguishable from a broken loadout menu.
 * OUTSIDE #lobby-settings, so it is never dimmed as a host control: it is the one
 * line a guest needs more than the host does. */
#lobby-preset-banner {
  margin: 0;
  padding: 9px 11px;
  font-family: var(--ui);
  font-size: 11.5px; line-height: 1.5; letter-spacing: 0.02em;
  border: 1px solid var(--rule-strong); color: var(--ink-3);
  background: rgba(255, 255, 255, 0.025);
}
#lobby-preset-banner[data-on="1"] {
  border-color: #8a6a26; color: #f0d9a8; background: rgba(224, 164, 55, 0.08);
}
#lobby-preset-banner b { color: var(--ink); font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; }

/* ==================== read-only, and visibly so ====================
 * "Everything nedes to be visible for both parties but ht ehost can change it."
 *
 * Three signals, because one is deniable: the panel steps back, every field the
 * host owns carries a HOST chip naming the owner, and the control itself refuses
 * the pointer. The VALUE stays at full contrast — dimming the number is how a
 * guest ends up unable to read the bot count they were shown for that purpose.
 */
#lobby-settings[data-role="client"] { opacity: 0.92; }
#lobby-settings[data-role="client"] .panel-label::after {
  content: " · HOST SETTING"; color: #d8ae5b; font-size: 0.8em; letter-spacing: 0.1em;
}
#lobby-settings[data-role="client"] .gsel-value,
#lobby-settings[data-role="client"] .step-val { color: var(--ink-2); }
/* The chip. Rendered from the attribute js/net/lobby-ui.js stamps, so a field
 * that is locked without being marked shows nothing and a marked field that is
 * not locked shows nothing — the affordance and the state cannot disagree.
 *
 * TWO FORMS, because the composition panel is not a <label>. A field's chip is a
 * `::after` on its caption; the composition slot has no caption to hang one on,
 * so js/net/lobby-ui.js inserts a real `.comp-lock` element there. `content` is
 * deliberately NOT set on the element form — `content` on a real element is inert,
 * so the chip would have been an empty box, which is the kind of styled layer that
 * never renders and reads as present to the next reader. The text comes from the
 * module, and validate-ui-contract asserts the class is actually produced. */
/* `label[...]`, NOT `[...]`. The composition slot's own `.comp-lock` chip IS a
 * `span:first-child` of a locked host-owned container, so the bare selector gave
 * it an ::after of its own and the panel rendered "HOST HOST". Scoping the pseudo
 * form to the <label> fields — the only elements that have a caption to hang it
 * on — makes the two forms disjoint by construction rather than by a :not(). */
/* `.field[...]`, not `label[...]`: the cheats row is a <div class="field">
 * because it wraps a GROUP of checkboxes rather than labelling one control.
 * Every host-owned field carries `.field`, and the composition slot does not,
 * so the pseudo form and the `.comp-lock` element form stay disjoint by
 * construction — which is what stopped the panel rendering "HOST HOST". */
.field[data-owner="host"][data-locked="1"] > span:first-child::after {
  content: "HOST";
  display: inline-block;
  margin-left: 8px;
  padding: 2px 5px;
  font-family: var(--ui);
  font-size: 8.5px; font-weight: 700; letter-spacing: 0.14em;
  color: var(--ink-3);
  background: var(--s3);
  vertical-align: 1px;
}
.comp-lock {
  display: none;
  width: max-content;
  margin: 0 0 8px;
  padding: 2px 5px;
  font-family: var(--ui);
  font-size: 8.5px; font-weight: 700; letter-spacing: 0.14em;
  color: var(--ink-3);
  background: var(--s3);
}
[data-owner="host"][data-locked="1"] > .comp-lock { display: inline-block; }
[data-owner="host"][data-locked="1"] { cursor: not-allowed; }
/* THE CUSTOM LISTBOX NOW MIRRORS `disabled` — this comment used to say it did not, and
 * leaving that standing would send the next reader to re-fix a closed defect.
 *
 * The defect it describes was real: js/ui-select.js built a <button class="gsel-trigger">
 * in front of the native <select> and never copied `disabled` onto it, so a guest could
 * open a host-owned listbox, commit a value, watch NetSession.setLobbySetup() correctly
 * refuse it, and be left reading a setting the store does not hold. js/net/lobby-ui.js
 * worked around it locally by reaching through .closest('.gsel') for the trigger.
 *
 * `build()` in js/ui-select.js now sets `trigger.disabled` from the select at
 * construction and keeps it in step with a MutationObserver on the attribute, and
 * `openList()` refuses outright when the select is disabled. So this rule is the visual
 * half only — and it is also the fallback for a browser without `:has`, since the
 * observer additionally puts `.gsel-disabled` on the wrapper. */
.gsel-disabled .gsel-trigger,
.gsel:has(select:disabled) .gsel-trigger {
  pointer-events: none;
  color: var(--ink-3);
  background: var(--s1);
}
.gsel-disabled .gsel-caret,
.gsel:has(select:disabled) .gsel-caret { opacity: 0.3; }
#comp-panel button[data-host-only="1"] { pointer-events: none; opacity: 0.5; }
#comp-slot-lobby[data-locked="1"] .stepper { cursor: not-allowed; }

/* ==================== cheats ====================
 * Real `<input type=checkbox>` elements, restyled by the rule further up this file
 * that draws only the box — so focus, the label's click target, keyboard toggling
 * and the accessibility tree are all untouched, and the checked state reuses the
 * accent that is already in the budget rather than adding one.
 *
 * The scope tag says WHERE the effect is simulated, which js/cheats.js is careful
 * to distinguish from who owns the switch: the host owns every switch, on both
 * scopes. A guest reads this row and changes nothing.
 */
.cheat-list { display: flex; flex-direction: column; gap: 7px; }
.cheat {
  display: flex; align-items: center; gap: 10px;
  padding: 5px 0;
  cursor: pointer;
  font-family: var(--ui);
  font-size: 12px; letter-spacing: 0.05em;
  color: var(--ink-2);
}
.cheat:hover { color: var(--ink); }
.cheat input[type=checkbox]:disabled { cursor: not-allowed; }
[data-locked="1"] .cheat { cursor: not-allowed; }
.cheat-name { text-transform: uppercase; font-weight: 600; }
/* On, and visible as such without a colour: the row it describes is live. The
 * checkbox already carries the accent; the row only needs to stop being muted. */
.cheat:has(input:checked) { color: var(--ink); }

/* ==================== composition ====================
 * Per-team bot counts. The design problem was communicating TWO OVERLAPPING CAPS
 * without a paragraph: MAX_TEAM_SIZE (6) caps a side, MAX_HUMANS (4) caps humans
 * across the lobby because it is a peer mesh, not a server.
 *
 * Neither is written down. The six pips per team ARE MAX_TEAM_SIZE, solid pips are
 * the humans already holding those slots, and the stepper stops at the open ones —
 * so the limit and the reason for it are the same glance. The human cap gets its
 * own four pips beside the roster, where humans are.
 *
 * ZERO ON BOTH SIDES IS LEGAL. Nothing here goes red, disabled or warning-coloured
 * for it: a lone player in an empty Rust is how the map gets inspected, and the
 * summary line says so plainly instead of nagging.
 */
#composition { display: flex; flex-direction: column; gap: 12px; }
.comp-team { display: grid; gap: 6px; }
.comp-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.comp-name {
  font-family: var(--ui);
  font-size: 11.5px; font-weight: 700; letter-spacing: 0.06em;
  text-transform: uppercase;
}
/* Team colour, the same friendly/hostile pair the HUD and the boards use. */
.comp-team[data-side=friendly] .comp-name { color: var(--blue); }
.comp-team[data-side=hostile] .comp-name { color: var(--red); }

/* THE PIPS. Six per team, and they carry the whole explanation. */
.comp-slots { display: flex; gap: 3px; flex: 0 0 auto; }
.comp-slots i {
  display: block; width: 9px; height: 9px;
  background: transparent;
  box-shadow: inset 0 0 0 1px var(--ink-4);
}
/* A human holds this slot: solid, in the team's colour. */
.comp-slots i.human { box-shadow: none; }
.comp-team[data-side=friendly] .comp-slots i.human { background: var(--blue); }
.comp-team[data-side=hostile] .comp-slots i.human { background: var(--red); }
/* A bot holds it: present, but plainly not a person. */
.comp-slots i.bot { background: var(--ink-4); box-shadow: none; }

.comp-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.comp-label {
  font-family: var(--ui);
  font-size: 11.5px; font-weight: 600; letter-spacing: 0.05em;
  color: var(--ink-3); text-transform: uppercase;
}

/* A stepper, not a <select> of thirteen options. Setting "one fewer bot" should be
 * one click, and the value has to be readable while you click it. */
.stepper { display: flex; align-items: stretch; background: var(--s2); }
.step-btn {
  appearance: none; border: 0;
  width: 30px;
  font-family: var(--ui); font-size: 15px; line-height: 1;
  color: var(--ink-2);
  background: transparent;
  cursor: pointer;
  transition: background var(--fast) ease, color var(--fast) ease;
}
.step-btn:hover:not(:disabled) { background: var(--s3); color: var(--ink); }
.step-btn:active:not(:disabled) { background: var(--s4); }
.step-btn:focus-visible { outline: 2px solid var(--blue); outline-offset: -2px; }
/* At a limit the button is out of moves. Quiet, not alarming — hitting the cap is
 * a normal thing to do, not an error. */
.step-btn:disabled { color: var(--ink-4); cursor: default; }
.step-val {
  min-width: 2.4ch; padding: 7px 2px;
  text-align: center;
  font-family: var(--num); font-variant-numeric: tabular-nums;
  font-size: 15px; font-weight: 700;
  color: var(--ink);
}

/* The consequence, stated once. Never a warning. */
#comp-total {
  margin: 2px 0 0;
  font-family: var(--ui);
  font-size: 11px; letter-spacing: 0.03em;
  color: var(--ink-3);
  line-height: 1.5;
}
#comp-total b { color: var(--ink-2); font-weight: 600; }

/* The legacy total select stays in the DOM for js/main.js's binding and is not for
 * a player to use. Clipped rather than `display: none`, so it keeps a layout box
 * and a form value that main.js can still read and write. */
.comp-legacy {
  position: absolute;
  width: 1px; height: 1px;
  overflow: hidden; clip: rect(0 0 0 0);
  white-space: nowrap;
}

/* FFA has no sides, so per-team counts are meaningless. One total, and the pip
 * rows go with the teams they described. */
#composition[data-mode=ffa] .comp-team[data-side=hostile] { display: none; }
#composition[data-mode=ffa] .comp-team[data-side=friendly] .comp-slots { display: none; }
#composition[data-mode=ffa] .comp-team[data-side=friendly] .comp-name { color: var(--ink-2); }

/* ==================== the action row ====================
 * `.sheet-foot` is already `position: sticky` — the reason is in its own comment
 * and it is why this screen may keep gaining bands without the deploy button
 * walking off the bottom. Nothing here may make it static.
 *
 * ONE primary per screen. A host sees READY and START MATCH at once, and both
 * were gold slabs shoulder to shoulder — two "the main thing" buttons, which
 * means neither is. START MATCH is the host's primary; READY demotes to a neutral
 * slab whenever it is on screen next to it. A guest has START MATCH hidden, and
 * READY keeps the gold, because for a guest readying up IS the deploy. */
.lobby-foot { gap: 12px; }
.lobby-foot:has(#btn-lobby-start:not(.hidden)) #btn-lobby-ready {
  background: var(--s2);
  color: var(--ink);
}
.lobby-foot:has(#btn-lobby-start:not(.hidden)) #btn-lobby-ready:hover { background: var(--s3); }
/* READY, once you are. A state, so it reads as one rather than as an action you
 * are being asked to take again. */
.lobby-foot #btn-lobby-ready[aria-pressed="true"] { color: var(--green); }

/* ============================== modal ============================== */
.modal {
  background: rgba(4, 6, 8, 0.78);
  backdrop-filter: blur(8px);
  display: grid; place-items: center;
  padding: 5vh 5vw;
  z-index: var(--z-modal);
}
.modal-card {
  background: var(--s1);
  border: 0;
  padding: clamp(24px, 3.4vw, 38px);
  display: flex; flex-direction: column; gap: 18px;
  width: min(460px, 100%);
  box-shadow: 0 40px 110px rgba(0, 0, 0, 0.8), inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.modal-card h2 {
  margin: 0;
  font-family: var(--display);
  font-size: 30px; letter-spacing: 0.07em; text-transform: uppercase;
}
.modal-actions { display: flex; gap: 12px; flex-wrap: wrap; }
.modal-actions.column { flex-direction: column; align-items: stretch; }
.modal-actions.column .btn { width: 100%; }
.modal-card .controls-legend {
  text-align: left;
  padding-top: 16px;
  border-top: 1px solid var(--rule);
  line-height: 2.2;
}

/* ====================== shell readiness ======================
 * Until js/main.js has attached the last menu listener, the controls are inert.
 * Say so, rather than letting a player press a live-looking button into the void.
 *
 * `pointer-events: none` rather than the `disabled` attribute, because the state is
 * transient and measured in a few hundred milliseconds: toggling a real attribute on
 * every control at boot means touching the DOM twice for something that resolves on
 * its own, and it would fight the `:disabled` styling that genuinely-unavailable
 * controls use. This is "not yet", not "not allowed".
 *
 * Scoped to the interactive screens on purpose. The device gate and the loading
 * screen must stay fully operable while the shell is still coming up — they are the
 * two surfaces whose whole job is to work before the game does.
 */
html:not([data-shell="ready"]) #menu .btn,
html:not([data-shell="ready"]) #menu .field,
html:not([data-shell="ready"]) #menu .menu-setup {
  opacity: 0.42;
  pointer-events: none;
}
/* A boot that FAILED is a different thing from a boot still running, and the toast
 * that says so is the only thing on screen worth reading. Dim the dead menu harder
 * so it does not compete with it. */
html[data-shell="failed"] #menu { opacity: 0.25; }

/* The full-screen suggestion. A shade wider than a confirm dialog because it
 * carries a paragraph of reasoning rather than a question — the player is being
 * told WHY, not just asked. */
.fs-card { width: min(540px, 100%); gap: 14px; }
.fs-card .sub { margin: 0; max-width: 46ch; }
.fs-card .modal-actions { margin-top: 4px; }
/* The remember box sits BELOW the actions, not above them. Above, it reads as a
 * condition on the buttons — "don't ask again, then choose" — and a player who
 * ticks it before deciding cannot tell which answer is about to be remembered. */
.fs-remember {
  display: flex; align-items: center; gap: 10px;
  font-size: 12px; letter-spacing: 0.08em;
  color: var(--ink-2); cursor: pointer;
}
.fs-remember input { margin: 0; }
.fs-card .fs-foot {
  margin: 0; padding-top: 14px;
  border-top: 1px solid var(--rule);
  font-size: 12px; color: var(--ink-3, var(--ink-2));
}
.fs-card .fs-foot kbd { font-size: inherit; }
/* The options row is a label wrapping a button rather than a control, so it does
 * not inherit the grid the sliders use. Match their rhythm explicitly. */
.opt > .btn.small { width: 100%; margin-top: 6px; }

/* ============================== loading ==============================
 * "Too long of a black loading screen" was two problems. One is real work —
 * 379 audio files decode at boot — and belongs to the shell. The other is that
 * the wait was ILLEGIBLE: one word, a 3px hairline, and no statement of what
 * was happening or how far along it was. That half is fixable here, and a
 * legible wait is shorter than an opaque one of the same length.
 */
#loading {
  position: fixed; inset: 0; z-index: var(--z-loading);
  display: grid;
  align-content: end;
  background:
    radial-gradient(120% 90% at 78% 8%, rgba(224, 164, 55, 0.07) 0%, transparent 58%),
    var(--s0);
  padding: clamp(24px, 6vh, 64px) clamp(22px, 6vw, 90px);
}
.load-inner { width: min(760px, 100%); text-align: left; }
.load-top { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; margin-bottom: 14px; }
#load-label {
  margin: 0;
  font-family: var(--display);
  font-size: clamp(26px, 3.2vw, 40px);
  line-height: 1;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  color: var(--ink);
}
#load-pct {
  font-family: var(--num);
  font-variant-numeric: tabular-nums;
  font-size: clamp(26px, 3.2vw, 40px);
  font-weight: 600;
  line-height: 1;
  color: var(--gold);
}
.load-track { height: 2px; background: var(--rule); overflow: hidden; }
#load-bar {
  /* POSITIONED, AND THAT IS load-bearing: `#load-claim` is absolutely positioned in the
   * same track, and an absolutely-positioned sibling paints ABOVE in-flow content with no
   * z-index of its own — so without this the dim claim covers the solid bar and the only
   * thing that means progress is the thing you cannot see. */
  position: relative; z-index: 1;
  display: block; height: 100%; width: 0%;
  background: var(--gold);
  /* A transition, NOT an animation: it interpolates between two values the work actually
   * reached. Nothing here advances on its own. */
  transition: width 220ms linear;
}
/* The stages, named. A player who can see that AUDIO is the slow one is not
 * waiting on a black screen any more, they are watching something happen. */
#load-steps {
  list-style: none;
  margin: 18px 0 0; padding: 0;
  display: flex; flex-wrap: wrap;
  gap: 6px clamp(18px, 3vw, 40px);
}
#load-steps li {
  font-family: var(--ui);
  font-size: 11px; font-weight: 600; letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--ink-4);
  display: flex; align-items: center; gap: 7px;
  transition: color var(--med) ease;
}
#load-steps li::before {
  content: ''; width: 5px; height: 5px;
  background: currentColor;
  transition: background var(--med) ease, transform var(--med) var(--ease);
}
#load-steps li.now { color: var(--ink); }
#load-steps li.now::before { background: var(--gold); transform: scale(1.5); }
#load-steps li.done { color: var(--ink-3); }
#load-steps li.done::before { clip-path: polygon(14% 46%, 0 62%, 40% 100%, 100% 22%, 86% 8%, 38% 70%); transform: scale(2.1); }
/* THE CURRENT STAGE'S CLAIM — how far this step reaches, not how far it has got.
 *
 * Sits behind #load-bar in the same track. Deliberately dim and deliberately WITHOUT a
 * transition on width: it must read as "this much is under way", never as movement. The
 * bar in front of it is the only thing that means progress. */
#load-claim {
  position: absolute; inset: 0 auto 0 0;
  display: block; height: 100%; width: 0%;
  background: color-mix(in srgb, var(--gold) 26%, transparent);
}
.load-track { position: relative; }
/* The audio library still decoding behind a match that has already started. Quiet
 * on purpose: it is a statement that work continues, not a second progress bar
 * competing with the one above it. */
#load-bg {
  margin: 10px 0 0;
  font-family: var(--ui);
  font-size: 11px; font-weight: 600; letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-4);
  font-variant-numeric: tabular-nums;
}
#load-bg[hidden] { display: none; }
/* WHO THE MATCH IS STILL WAITING FOR. Gold rather than the decode line's grey, because
 * this is the reason the bar has stopped at 100% — the one thing a player staring at a
 * finished bar wants explained — and not a footnote about work continuing. Sentence case
 * and no letter-spacing: it is a sentence, not a label. */
#load-waiting {
  margin: 12px 0 0;
  font-family: var(--ui);
  font-size: 12.5px; line-height: 1.55;
  color: var(--gold);
  max-width: 62ch;
  font-variant-numeric: tabular-nums;
}
#load-waiting[hidden] { display: none; }
#load-tip {
  margin: 22px 0 0;
  font-size: 12.5px; line-height: 1.6;
  color: var(--ink-3);
  max-width: 62ch;
  min-height: 2.6em;
}
#load-tip b { color: var(--ink-2); font-weight: 600; }

/* ============================== toast ============================== */
#toast {
  position: fixed; left: 50%; bottom: 42px; transform: translateX(-50%);
  z-index: var(--z-toast);
  padding: 12px 18px;
  font-family: var(--ui);
  font-size: 13.5px; letter-spacing: 0.02em;
  color: var(--ink);
  background: var(--s3);
  box-shadow: 0 16px 40px rgba(0, 0, 0, 0.6);
  max-width: min(560px, 88vw);
  text-align: left;
}

/* ==================== the cinematic veil ====================
 * The match-intro fade. js/cinematics/ owns the behaviour and was INJECTING these
 * rules from JS to avoid conflicting with this file; landed here so it stops being
 * injected and so the z-index comes from the same scale as everything else.
 *
 * docs/CINEMATICS.md §7c specifies "above the canvas, below the HUD", which is a
 * relationship rather than a number — the canvas is --z-bg and the HUD is --z-hud, so
 * the veil belongs between them. Written against the tokens rather than as a literal,
 * because a literal is how --z-fx ended up colliding with --z-modal.
 *
 * `opacity: 0` at rest and js/game.js drives it. It must never take pointer events:
 * a full-screen element over a live canvas eats clicks meant to be shots. */
#cine-veil {
  position: fixed; inset: 0;
  background: #000;
  opacity: 0;
  pointer-events: none;
  z-index: calc(var(--z-hud) - 1);
}

/* ============================== device gate ==============================
 * Mouse and keyboard only, and an honest page about it rather than a game that
 * loads and then cannot be played. See js/device-gate.js for the detection.
 */
html.device-blocked #menu,
html.device-blocked #classes,
html.device-blocked #options,
html.device-blocked #lobby,
html.device-blocked #join-modal,
html.device-blocked #pause,
html.device-blocked #hud,
html.device-blocked #loading,
html.device-blocked #game-canvas { display: none !important; }
html.device-blocked, html.device-blocked body { overflow: auto; }

#device-gate {
  position: fixed; inset: 0;
  z-index: var(--z-gate);
  background:
    linear-gradient(180deg, rgba(255, 255, 255, 0.04) 0%, transparent 30%),
    var(--s0);
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  /* SAFE CENTERING, AND THIS ONE WORD IS THE WHOLE BUG.
   *
   * Plain `align-content: center` on a grid whose content is TALLER than the
   * container overflows in both directions at once — half above the top edge and
   * half below. The half above is unreachable: scrolling only ever reveals what
   * is below the start edge, so the overflowing top is simply gone.
   *
   * On a 667px iPhone SE this gate is about 900px tall, and the symptom was
   * exactly that: the attribution clipped mid-sentence at the bottom with no way
   * to scroll to it. `safe` says "centre when it fits, align to start when it
   * does not", which is what centring was always supposed to mean here.
   *
   * Paired with `min-height: 100dvh` because `100vh` on mobile Safari is the
   * viewport WITHOUT the address bar, so a full-height overlay measured in vh is
   * taller than the screen from the moment it loads. */
  display: grid; align-content: safe center; justify-items: center;
  min-height: 100dvh;
  padding:
    max(clamp(24px, 6vh, 72px), env(safe-area-inset-top))
    max(clamp(20px, 6vw, 90px), env(safe-area-inset-right))
    max(clamp(32px, 6vh, 72px), env(safe-area-inset-bottom))
    max(clamp(20px, 6vw, 90px), env(safe-area-inset-left));
}
.dg-inner { width: min(640px, 100%); }
.dg-mark {
  font-family: var(--display);
  font-size: clamp(28px, 6vw, 44px);
  line-height: 0.9;
  letter-spacing: 0.02em;
  text-transform: uppercase;
  margin: 0 0 clamp(20px, 4vh, 34px);
}
.dg-mark em { font-style: normal; color: var(--gold); display: block; }
#device-gate h1 {
  margin: 0 0 14px;
  font-family: var(--display);
  font-size: clamp(24px, 4.4vw, 36px);
  line-height: 1.02;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  text-wrap: balance;
}
#device-gate p { margin: 0 0 14px; font-size: 14.5px; line-height: 1.65; color: var(--ink-2); max-width: 60ch; }
#device-gate .dg-why { color: var(--ink-3); font-size: 13px; }
.dg-needs { list-style: none; margin: 22px 0 0; padding: 20px 0 0; border-top: 1px solid var(--rule-strong); display: grid; gap: 12px; }
.dg-needs li { display: grid; grid-template-columns: 20px 1fr; gap: 12px; font-size: 13.5px; color: var(--ink-2); align-items: start; }
.dg-needs .dg-state { font-weight: 600; font-size: 13px; line-height: 1.5; }
.dg-needs .ok { color: var(--green); }
.dg-needs .no { color: var(--red); }
.dg-needs b { color: var(--ink); font-weight: 600; display: block; font-size: 13.5px; }
.dg-needs span.dg-note { color: var(--ink-3); font-size: 12.5px; }
.dg-foot { margin-top: 26px; padding-top: 18px; border-top: 1px solid var(--rule); font-size: 11.5px; color: var(--ink-4); }
#device-gate .btn { margin-top: 24px; }

/* The way out of the gate for a phone.
 *
 * 52px tap targets — comfortably over the 44px floor for a thumb — because this
 * overlay is the only surface a touch device ever gets and these are the only
 * two things on it that go anywhere. They stretch to full width on a narrow
 * screen: a pair of pill-width links floating in a 375px column reads as
 * incidental, and these are the primary action there.
 *
 * `.dg-primary` is filled gold and appears only when the override button does
 * NOT — on a phone there is no "start the game" action, so the archive becomes
 * the one thing the screen is asking you to do, and it should look like it. */
.dg-links { display: flex; flex-wrap: wrap; gap: 10px; margin-top: clamp(22px, 4vh, 30px); }
.dg-links a {
  display: inline-flex; align-items: center; justify-content: center;
  flex: 1 1 auto; min-height: 52px; padding: 0 20px;
  background: var(--s2); color: var(--ink); text-decoration: none;
  font-family: var(--display); font-size: 16px; letter-spacing: 0.08em; text-transform: uppercase;
  transition: background var(--fast) ease;
}
.dg-links a:hover, .dg-links a:focus-visible { background: var(--s3); }
.dg-links a.dg-primary { background: var(--gold); color: #14100a; }
.dg-links a.dg-primary:hover, .dg-links a.dg-primary:focus-visible { background: #efb349; }

.dg-foot a.dg-credit {
  color: var(--ink-2);
  text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 2px;
  text-decoration-color: color-mix(in srgb, var(--ink) 40%, transparent);
  transition: text-decoration-color 140ms var(--ease, ease);
}
.dg-foot a.dg-credit:hover, .dg-foot a.dg-credit:focus-visible { text-decoration-color: var(--ink); }

/* A 375x667 phone is the smallest real screen this ever renders on, and the gate
   is the one page guaranteed to be read there. Everything tightens: the wordmark
   stops being a display piece, the body drops to the smallest comfortable
   reading size, and the vertical rhythm halves. */
@media (max-width: 480px) {
  #device-gate h1 { font-size: clamp(26px, 7.6vw, 34px); }
  .dg-mark { font-size: clamp(22px, 6.4vw, 30px); margin-bottom: 18px; }
  #device-gate p { font-size: 14px; margin-bottom: 12px; }
  #device-gate .dg-why { font-size: 12.5px; }
  .dg-needs { margin-block: 18px; }
  .dg-links { flex-direction: column; gap: 8px; }
  .dg-links a { width: 100%; font-size: 15px; }
  #device-gate .btn { width: 100%; margin-top: 18px; text-align: center; }
  .dg-foot { margin-top: 20px; padding-top: 14px; font-size: 11px; }
}

/* ============================== HUD ============================== */
/* THE HUD DOES NOT PAINT UNTIL THERE IS A WORLD UNDER IT.
 *
 * Set by `HUD.setPreload(true)` for the whole match build and cleared by `hideLoading`.
 * See the note on that method in js/hud.js for the measurement: the HUD is un-hidden
 * 9 ms after the loading veil goes up and stays up for 2617 ms, so today it is invisible
 * only because `#loading` happens to be opaque and happens to outrank it. This makes the
 * property explicit instead of emergent.
 *
 * OPACITY RATHER THAN `display`, and that is load-bearing rather than stylistic: the
 * radar's `_dialRadius` caches a `clientWidth`, which reads 0 on a `display: none`
 * element and would latch a wrong dial size for the entire match. The box must stay
 * laid out; only the paint goes away. */
#hud.hud-preload { opacity: 0 !important; pointer-events: none !important; }
#hud {
  position: fixed; inset: 0; z-index: var(--z-hud);
  pointer-events: none;
  font-family: var(--display);
  text-shadow: var(--hud-shadow);
  user-select: none;
}

/* --- score header ---
 * TOP CENTRE, and now alone up there — the compass strip above it was removed at
 * the user's request. Because it now sits near the top edge it also has to hold
 * up at narrow widths: the columns are clamped rather than fixed so the two
 * scores never collide with the clock. */
/* The HUD sits over a bright desert sky, so every glyph here carries its own
 * contrast rather than relying on the scene behind it. The user reported the score
 * block as unreadable against a pale sky, and it was: 10.5px at --ink-2 over a
 * near-white background is invisible whatever the colour token says.
 *
 * Two fixes, and both are needed. A soft dark scrim behind the block gives the type
 * something to sit on without drawing a panel; and every glyph gets a text-shadow,
 * because the scrim alone cannot cover the score numerals that overhang it. */
#score-header {
  position: absolute; top: 12px; left: 50%; transform: translateX(-50%);
  display: grid;
  /* Wide enough for a faction name. At 108px max the shell session's larger,
   * heavier label (raised for legibility over sky) no longer fitted, so NORTHSTAR
   * and CROSSFIRE ellipsised to "NORTHST…" and "CROSSFI…" — a truncated faction
   * name is worse than a small one, because the two sides can end up reading the
   * same. */
  grid-template-columns: clamp(96px, 10.5vw, 148px) clamp(58px, 6vw, 78px) clamp(96px, 10.5vw, 148px);
  align-items: start;
  gap: clamp(8px, 1.2vw, 16px);
  max-width: calc(100vw - 32px);
  padding: 6px clamp(14px, 1.6vw, 22px) 8px;
  border-radius: 3px;
  background: radial-gradient(120% 160% at 50% 0%, rgba(4, 6, 8, 0.52) 0%, rgba(4, 6, 8, 0.28) 62%, rgba(4, 6, 8, 0) 100%);
  text-shadow: 0 1px 3px rgba(0, 0, 0, 0.85), 0 0 1px rgba(0, 0, 0, 0.9);
}
.sh-side { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: baseline; gap: 4px 8px; }
.sh-side.right { direction: rtl; }
.sh-side.right > * { direction: ltr; }
/* TEAM COLOUR REACHES THE HUD — and `--ink-1` does not exist.
 *
 * Two things were wrong here. `color: var(--ink-1)` referenced a token that is
 * declared nowhere in this file (the ramp is --ink, --ink-2, --ink-3, --ink-4),
 * so the declaration was invalid and the colour silently fell through to the
 * inherited value. It happened to look right, which is the dangerous kind of
 * wrong: nothing was setting that colour on purpose.
 *
 * And both scores were the same white, so the one thing the score plate exists
 * to tell you — which of these two numbers is yours — was carried by position
 * alone. Team colour was already on the 2px track directly beneath each score
 * and on the scoreboard's team headings, and never reached the plate. The
 * faction NAME now takes the same friendly/hostile colour as its own track.
 * The size and weight the shell session raised for legibility over bright sky
 * are kept. */
.sh-label {
  font-family: var(--ui);
  font-size: 12px; font-weight: 700; letter-spacing: 0.1em;
  color: var(--blue);
  grid-column: 1;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  text-transform: uppercase;
}
.sh-side.right .sh-label { color: var(--red); }
#score-header.ffa .sh-side.right .sh-label { color: var(--gold); }
.sh-score {
  font-size: clamp(24px, 2.4vw, 32px); line-height: 1; grid-column: 2;
  font-family: var(--num); font-weight: 700; font-variant-numeric: tabular-nums;
}
.sh-bar { grid-column: 1 / -1; height: 2px; background: rgba(255, 255, 255, 0.16); }
.sh-bar i { display: block; height: 100%; background: currentColor; transform-origin: left; transform: scaleX(0); transition: transform var(--med) ease; }
.sh-side .sh-bar { color: var(--blue); }
.sh-side.right .sh-bar { color: var(--red); }
.sh-timer {
  font-family: var(--num); font-weight: 700;
  font-size: clamp(19px, 1.9vw, 25px); text-align: center; letter-spacing: 0.01em;
  font-variant-numeric: tabular-nums;
}
.sh-timer.urgent { color: var(--red); animation: pulse 1s steps(2) infinite; }
@keyframes pulse { 50% { opacity: 0.45; } }
#score-header.ffa .sh-side.right .sh-bar { color: var(--gold); }

/* --- radar ---
 * Top-left, where MW2019 and MW2 2009 both put it. Circular rather than square:
 * a round dial reads as a range ring, which is what it is. */
#minimap {
  position: absolute; left: 20px; top: 18px;
  /* Was clamp(96px, 9.5vw, 138px) — the user could not read the map inside it, and
   * at 138px on a 1080p screen the whole 44 m yard is about a centimetre across.
   * The reference radars are substantially larger relative to frame. */
  width: clamp(150px, 13.5vw, 214px); height: clamp(150px, 13.5vw, 214px);
  border-radius: 50%;
  /* AN OPAQUE PLATE, AND THAT IS THE POINT RATHER THAN A DARKER LOOK.
   *
   * Two previous passes moved this alpha and both were reasoning about the wrong
   * quantity. The first sat at 0.62 and was described as "washing out the thing it
   * exists to show"; the second dropped it to 0.30..0.10 so the map could be read
   * "through" it. Neither could work, because at any alpha below 1 THE BACKDROP OF THE
   * RADAR IS THE LIVE 3-D SCENE — so the contrast of the map geometry is whatever the
   * player happens to be looking at, and over bright ground or sky it fell to about
   * zero. The user's report is the outcome: "radar is really hard to see".
   *
   * A radar is an instrument and an instrument has a face. Opaque makes the geometry's
   * contrast a property of the design instead of a property of the view direction, and
   * it is the only version of this that has a fixed answer. The faint inner-to-outer
   * gradient survives at full alpha because it is what keeps the dial from reading as a
   * flat sticker; it no longer decides whether anything is visible.
   *
   * js/hud.js's buildMinimapGeometry raises the ink alphas to use the headroom this
   * buys, and drops the canvas's own 0.85 multiplier. Costs no frame time — a
   * background colour, painted once, and one FEWER translucent layer to composite. */
  background: radial-gradient(circle, rgb(19, 23, 27) 0%, rgb(14, 17, 20) 70%, rgb(9, 11, 13) 100%);
  box-shadow: inset 0 0 0 1px rgba(232, 234, 236, 0.22), 0 2px 10px rgba(0, 0, 0, 0.35);
  overflow: hidden;
}
#minimap-rings i {
  position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
  border: 1px solid rgba(232, 234, 236, 0.09); border-radius: 50%;
}
#minimap-rings i:nth-child(1) { width: 44%; height: 44%; }
#minimap-rings i:nth-child(2) { width: 76%; height: 76%; }
#minimap-dots { position: absolute; left: 50%; top: 50%; width: 0; height: 0; }
.mm-dot {
  position: absolute; left: -3px; top: -3px; width: 6px; height: 6px;
  border-radius: 50%; background: var(--blue);
  transition: opacity 160ms linear;
}
.mm-dot.enemy { background: var(--red); border-radius: 1px; }
.mm-dot.me { display: none; }
/* Own position and facing: a chevron at the centre, because the dial rotates
 * under the player rather than the player rotating on the dial. */
#minimap-self {
  position: absolute; left: 50%; top: 50%; margin: -6px 0 0 -5px;
  width: 10px; height: 12px;
  background: #f2f5f7;
  clip-path: polygon(50% 0, 100% 100%, 50% 76%, 0 100%);
}
#minimap-cone {
  position: absolute; left: 50%; top: 50%; width: 0; height: 0;
  border-left: 34px solid transparent; border-right: 34px solid transparent;
  border-bottom: 52px solid rgba(242, 245, 247, 0.07);
  transform: translate(-50%, -100%);
}
#minimap-map {
  position: absolute; left: 50%; top: 50%;
  width: 256px; height: 256px; margin: -128px 0 0 -128px;
  transform-origin: 50% 50%;
  will-change: transform;
  /* NO `opacity` HERE. It was 0.85, and it was a second multiplier on a value chosen
   * once: the fills in buildMinimapGeometry already carry their own alpha, so the
   * effective ink was the product of two numbers picked in different files. One owner
   * for the ink, and it is the canvas. */
}

/* --- compass: REMOVED ---
 *
 * There was a 320px cardinal strip with named callout markers here, built from the
 * Black Ops 7 reference. The user played it and asked for it gone: "get rid of this
 * shit at the top ... i dont need a compass rn". It was a good element and it worked —
 * 0 label overlaps across a 360-degree sweep, live bearings computed per position —
 * and none of that matters against someone who does not want it on their screen.
 *
 * Deleted rather than hidden behind a flag, so it cannot come back by accident and so
 * nothing is left paying for it. Rust's callout bearings are still in docs/RUST.md if
 * they are wanted somewhere else later.
 */

/* --- crosshair --- */
#crosshair {
  --gap: 8px;
  position: absolute; left: 50%; top: 50%;
  width: 0; height: 0;
  transition: opacity 90ms linear;
}
#crosshair i {
  position: absolute; background: rgba(240, 242, 244, 0.92);
  box-shadow: 0 0 2px rgba(0, 0, 0, 0.95);
}
#ch-top, #ch-bottom { width: 2px; height: 7px; left: -1px; }
#ch-left, #ch-right { width: 7px; height: 2px; top: -1px; }
#ch-top { bottom: var(--gap); }
#ch-bottom { top: var(--gap); }
#ch-left { right: var(--gap); }
#ch-right { left: var(--gap); }
#ch-dot { width: 2px; height: 2px; left: -1px; top: -1px; opacity: 0.7; }
/* Aiming through irons: the sight IS the reticle, so the reticle goes. */
#crosshair.ads i { opacity: 0; }
#crosshair.ads #ch-dot { opacity: 0.35; }

#hitmarker { position: absolute; left: 50%; top: 50%; width: 0; height: 0; opacity: 0; }
#hitmarker i {
  position: absolute; width: 10px; height: 2px; background: #f2f4f6;
  box-shadow: 0 0 3px rgba(0, 0, 0, 0.9);
}
#hitmarker i:nth-child(1) { transform: rotate(45deg) translate(9px, 0); }
#hitmarker i:nth-child(2) { transform: rotate(-45deg) translate(9px, 0); }
#hitmarker i:nth-child(3) { transform: rotate(135deg) translate(9px, 0); }
#hitmarker i:nth-child(4) { transform: rotate(-135deg) translate(9px, 0); }
#hitmarker.show { animation: hm 180ms ease-out; }
#hitmarker.show.kill i { background: var(--red); height: 3px; }
#hitmarker.show.head i { background: var(--gold); }
@keyframes hm {
  0% { opacity: 1; transform: scale(0.7); }
  100% { opacity: 0; transform: scale(1.25); }
}

/* THE SNIPER RETICLE WAS HALF A CROSSHAIR.
 *
 * Reported: *"snipers dont have horizontal line in the scope? its not a full crosshair in
 * solo or multiplayer or killcams."* There was exactly one rule here — a `::after` with
 * `width: 1px; height: 26%` — so the scope has only ever drawn the VERTICAL arm. There was
 * no `::before` and no horizontal counterpart.
 *
 * BOTH ARMS COME FROM ONE LENGTH, AND THAT IS THE WHOLE DIFFICULTY. The obvious repair is
 * a `::before` with `width: 26%`, and it is wrong: percentages resolve against different
 * axes, so 26% of WIDTH on a 16:9 window is 499 px against 26% of HEIGHT at 281 px — a
 * cross with one arm nearly twice the other, which reads worse than the missing line did.
 * `vmin` is the same number of pixels whichever axis it is applied to, so one custom
 * property drives both and the cross is square at every aspect ratio by construction
 * rather than at the one the author happened to have open.
 *
 * 26vmin also REPRODUCES the shipped vertical arm exactly on any landscape window, where
 * `vmin` is the height — so this adds the missing arm without resizing the one that was
 * already there.
 *
 * AND IT STAYS INSIDE THE APERTURE. The clear circle is 26% of the gradient's
 * farthest-corner ray, which is always longer than half the short side, so a 26vmin cross
 * (13vmin per arm from the centre) sits inside it at 4:3, 16:9 and ultrawide alike —
 * measured at all three in scripts/_probe/scope-reticle.mjs rather than eyeballed once.
 */
#scope-overlay {
  position: absolute; inset: 0;
  --scope-arm: 26vmin;
  /* OPAQUE, not 0.97. At 0.97 three per cent of the world composited through the scope
     surround by arithmetic — a magnified muzzle flash or a bright sky read as a glow
     around the outside of the aperture, which is not something a real eyepiece does. The
     value bought nothing: this is the black OUTSIDE the sight picture, and there is
     nothing behind it the player is meant to see. */
  background: radial-gradient(circle at 50% 50%, transparent 0 26%, rgb(0, 0, 0) 30% 100%);
}
#scope-overlay::after,
#scope-overlay::before {
  content: ''; position: absolute; left: 50%; top: 50%;
  background: rgba(0, 0, 0, 0.8); transform: translate(-50%, -50%);
}
/* vertical arm */
#scope-overlay::after { width: 1px; height: var(--scope-arm); }
/* horizontal arm — the one that was never drawn */
#scope-overlay::before { width: var(--scope-arm); height: 1px; }

/* ==================== blood ====================
 * REBUILT. The user: "the blood showing up on the screen is aggressive and not
 * realistic at all — it should be around corners just like cod and be much more
 * tasteful."
 *
 * What it was: `bloodSvg()` in js/hud.js emits eleven hard-edged ellipses and
 * fourteen hard circles at `fill="rgba(96,8,12,0.82)"`, in a 34vmin box, at 0.92
 * opacity. On screen that is a clump of flat opaque dark-red circles — clip art,
 * and read as such.
 *
 * The geometry belongs to js/hud.js, which is not ours. Three things fix the
 * APPEARANCE from here, and all three are ours:
 *
 *   1. `fill` on the SVG group is a PRESENTATION ATTRIBUTE, which loses to any
 *      stylesheet rule. So the colour and alpha are overridable from CSS.
 *   2. `filter: url(#blood-spatter)` — a turbulence + displacement + blur filter
 *      declared in index.html — breaks the circles into irregular, soft-edged
 *      spatter with real texture. Circles in, spatter out.
 *   3. `#blood` carries a RADIAL MASK, so the centre of the frame is clear no
 *      matter where hud.js decides to place a hit. That is the "around corners
 *      like COD" requirement, and because it is a mask rather than a placement
 *      rule it cannot be defeated by a future change to the geometry. Nothing
 *      can ever obscure the crosshair.
 *
 * `transform` on .bl-spat is entirely ours (hud.js only sets --rot, --sz, left
 * and top), so the box is enlarged to put more of each spatter out where the
 * mask lets it through.
 */
#blood {
  position: absolute; inset: 0;
  overflow: hidden;
  /* Fully clear out to 26% of the half-diagonal, half strength at 55%, full only
   * at the very edge — so the corners carry the most and the middle carries
   * none. The crosshair sits at dead centre and is therefore never touched, and
   * because this is a MASK rather than a placement rule it holds no matter where
   * js/hud.js puts a hit. */
  -webkit-mask-image: radial-gradient(ellipse 76% 72% at 50% 50%,
    transparent 0%, transparent 26%, rgba(0, 0, 0, 0.45) 55%, #000 88%);
  mask-image: radial-gradient(ellipse 76% 72% at 50% 50%,
    transparent 0%, transparent 26%, rgba(0, 0, 0, 0.45) 55%, #000 88%);
}
/* SIZE IS LOAD-BEARING HERE, and getting it wrong is what produced both bad
 * versions of this overlay.
 *
 * `bloodSvg()` draws its droplets in a 0-100 viewBox with
 * `preserveAspectRatio="none"`, so EVERY droplet scales with this box. At 34vmin
 * and no blur they were crisp circles — the "clip art" the user saw. At 62vmin
 * they became soft pink clouds the size of a fist, which is worse. 40vmin is the
 * size at which a droplet stays droplet-shaped and the whole spatter still
 * reaches an edge on most bearings. */
.bl-spat {
  position: absolute;
  width: 40vmin; height: 40vmin;
  margin: -20vmin 0 0 -20vmin;
  transform: rotate(var(--rot, 0deg)) scale(var(--sz, 1));
  opacity: 0;
  animation: blSpat 260ms ease-out forwards;
  /* The texture. Displacement first so the edges are already irregular before
   * the blur softens them, then a slight blur so nothing has a vector edge. */
  filter: url(#blood-spatter) blur(0.5px);
  mix-blend-mode: multiply;
}
.bl-spat svg { width: 100%; height: 100%; display: block; }
/* FINE SPECKLE, authored here rather than in the SVG.
 *
 * The reference's blood is mostly small, sharp droplets, and `bloodSvg()`'s
 * smallest is a 0.6-2.2 unit circle — which at any usable box size is still a
 * blob. Rather than ask for the geometry to change before this can look right,
 * this adds the fine layer directly: eleven irregularly placed hard-stop radial
 * gradients at sub-degree sizes. They go through the same displacement filter as
 * the parent, so they break up and scatter instead of reading as a dot pattern.
 * It is what makes the overlay read as spray rather than as a stain. */
.bl-spat::after {
  content: '';
  position: absolute; inset: -8%;
  background-image:
    radial-gradient(circle at 18% 24%, rgba(126, 10, 16, 0.72) 0 1.1px, transparent 1.6px),
    radial-gradient(circle at 71% 12%, rgba(126, 10, 16, 0.64) 0 0.9px, transparent 1.4px),
    radial-gradient(circle at 39% 63%, rgba(126, 10, 16, 0.7) 0 1.4px, transparent 2px),
    radial-gradient(circle at 88% 47%, rgba(126, 10, 16, 0.6) 0 0.8px, transparent 1.3px),
    radial-gradient(circle at 8% 71%, rgba(126, 10, 16, 0.66) 0 1.2px, transparent 1.8px),
    radial-gradient(circle at 57% 88%, rgba(126, 10, 16, 0.62) 0 1px, transparent 1.5px),
    radial-gradient(circle at 29% 41%, rgba(126, 10, 16, 0.58) 0 0.7px, transparent 1.2px),
    radial-gradient(circle at 80% 76%, rgba(126, 10, 16, 0.68) 0 1.3px, transparent 1.9px),
    radial-gradient(circle at 48% 18%, rgba(126, 10, 16, 0.6) 0 0.9px, transparent 1.4px),
    radial-gradient(circle at 63% 55%, rgba(126, 10, 16, 0.55) 0 0.8px, transparent 1.3px),
    radial-gradient(circle at 12% 92%, rgba(126, 10, 16, 0.64) 0 1.1px, transparent 1.7px);
  background-size: 37% 41%, 43% 33%, 31% 47%, 53% 29%, 27% 39%, 47% 43%,
                   33% 31%, 41% 37%, 29% 49%, 39% 27%, 51% 35%;
  background-repeat: repeat;
  opacity: 0.85;
}
/* Beats the `fill` presentation attribute in hud.js — a stylesheet rule always
 * wins over a presentation attribute, which is what makes the colour ours.
 *
 * Tuned against a bright Rust sky, twice. At 0.34 fill × 0.62 opacity it had
 * swung past "tasteful" into invisible: on screen it read as dust haze and no
 * longer said "you are being hit", which is the one job it has. This is the
 * settled balance — clearly blood at the edges, never a blindfold. */
.bl-spat svg g { fill: rgba(132, 11, 17, 0.4); }
.bl-spat.out { animation: blFade 1100ms ease-in forwards; }
@keyframes blSpat {
  0%   { opacity: 0; transform: rotate(var(--rot, 0deg)) scale(calc(var(--sz, 1) * 0.72)); }
  100% { opacity: 0.66; transform: rotate(var(--rot, 0deg)) scale(var(--sz, 1)); }
}
@keyframes blFade { to { opacity: 0; } }
/* The persistent wash under a third health. Corners and edges, built from four
 * corner gradients plus a rim — not an inset box-shadow ring, which is what made
 * it read as a red border. It is NOT inside the mask, so it is declared on the
 * element rather than as a child, and it stays well clear of centre. */
#blood.critical::after {
  content: '';
  position: absolute; inset: 0;
  background:
    radial-gradient(58% 52% at 0% 0%,   rgba(122, 10, 14, 0.44), transparent 72%),
    radial-gradient(58% 52% at 100% 0%, rgba(122, 10, 14, 0.40), transparent 72%),
    radial-gradient(62% 56% at 0% 100%, rgba(104, 8, 12, 0.46), transparent 72%),
    radial-gradient(62% 56% at 100% 100%, rgba(104, 8, 12, 0.42), transparent 72%),
    radial-gradient(120% 108% at 50% 50%, transparent 46%, rgba(96, 6, 10, 0.34) 100%);
  animation: blPulse 2.4s ease-in-out infinite;
}
@keyframes blPulse { 50% { opacity: 0.68; } }

/* --- kill feedback --- */
#kill-marks { position: absolute; inset: 0; }
.kc-pop {
  position: absolute; transform: translate(-50%, -50%);
  display: flex; flex-direction: column; align-items: center; gap: 1px;
  animation: kcPop 1500ms var(--ease) forwards;
  white-space: nowrap;
}
.kcp-score { font-family: var(--num); font-weight: 700; font-size: 26px; color: var(--gold); }
.kcp-label { font-size: 10px; letter-spacing: 0.24em; color: var(--ink); }
.kcp-medal { display: flex; align-items: center; gap: 4px; font-size: 9px; letter-spacing: 0.18em; color: var(--gold); }
/* The medal glyph. `medalGlyph()` in js/hud.js puts `class="mg"` on the <svg> and
 * nothing styled it, so an inline SVG sat on the text baseline and pushed the
 * medal label a couple of pixels off its own row. Found by
 * scripts/validate-ui-contract.mjs, which is the entire reason that check
 * examines JS-generated class names. */
.mg { display: block; flex: 0 0 auto; }
@keyframes kcPop {
  0% { opacity: 0; transform: translate(-50%, -30%) scale(0.8); }
  14% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
  70% { opacity: 1; }
  100% { opacity: 0; transform: translate(-50%, -140%) scale(1); }
}
.kc-skull {
  position: absolute; left: 0; top: 0; margin: -11px 0 0 -11px;
  color: rgba(240, 242, 244, 0.86);
  filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.9));
  transition: opacity 200ms linear;
}
/* Directly above the killfeed, because it is the same information emphasised.
 * A filled tint rather than a red edge stripe. */

#damage-dirs { position: absolute; left: 50%; top: 50%; width: 0; height: 0; }
.dmg-dir {
  position: absolute; left: -60px; top: -120px; width: 120px; height: 34px;
  transform-origin: 60px 120px;
  background: radial-gradient(ellipse at 50% 100%, rgba(228, 89, 108, 0.85), transparent 72%);
  animation: dmgfade 900ms ease-out forwards;
}
@keyframes dmgfade { 0% { opacity: 0.95; } 100% { opacity: 0; } }

/* --- killfeed ---
 * BOTTOM LEFT, on the user's instruction. It also reads better there than top-right
 * did: the bottom-left corner is otherwise empty now the health bar is gone, and a
 * feed that grows upward from a corner never crosses the part of the screen you aim
 * at. Your own kills are marked by a FILLED row, not an accent stripe. */
/* THE STACK OWNS THE POSITION; the banner and the feed inside it own only their
 * own look. Bottom-left, growing upward, so it never crosses the part of the
 * screen you aim at — and the banner rides on top of the feed however tall the
 * feed gets, instead of being pinned at an offset that four rows fit under and
 * five do not. */
#feed-stack {
  position: absolute; left: 20px; bottom: 22px;
  display: flex; flex-direction: column; align-items: flex-start; gap: 8px;
  max-width: min(46vw, 520px);
  /* The stack is layout, not a surface: clicks belong to the game under it. */
  pointer-events: none;
}
#killfeed {
  display: flex; flex-direction: column; align-items: flex-start; gap: 3px;
  width: 100%;
}
.kf-row {
  display: flex; align-items: center; gap: 8px;
  font-size: 15px; letter-spacing: 0.03em;
  background: rgba(8, 10, 12, 0.55);
  padding: 4px 10px;
  animation: kfin 180ms ease-out;
  max-width: 100%;
}
.kf-row.mine { background: rgba(224, 164, 55, 0.22); color: #fff4de; }
.kf-row.out { opacity: 0; transition: opacity 300ms linear; }
.kf-icon { color: var(--ink-2); font-size: 13px; display: flex; flex: 0 0 auto; }
.kf-row.mine .kf-icon { color: var(--gold); }
/* hud.js sets `color` inline per name from the team palette. The row is display
 * type, so the names take the display face; they are the only words in it. */
.kf-name {
  font-family: var(--display);
  letter-spacing: 0.05em; text-transform: uppercase;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  min-width: 0;
}
/* THE BOT BADGE — deliberately not the name's typeface, and deliberately not the
 * name's colour.
 *
 * Requested as *"[bot] in killfeed for bot players (and in diff font / style or a badge
 * thing)"*. The whole job of this element is to read as an ANNOTATION rather than as
 * part of the callsign, so it changes every axis the name is carrying: the mono face
 * against the name's display face, a boxed ground against the name's bare text, and a
 * neutral ink that ignores the friendly/enemy colour the name is painted with. If it
 * inherited the team colour it would read as more name.
 *
 * `flex: 0 0 auto` because `.kf-name` is the element allowed to ellipsis: a long callsign
 * must eat itself before it eats the badge, or the one row where the distinction matters
 * most — a crowded feed — is the row that drops it.
 *
 * No accent colour: `validate-ui-slop` caps accent-as-surface at 14 rules and the budget
 * is at exactly 14. This wants to be quiet anyway. */
.kf-bot {
  flex: 0 0 auto;
  margin-left: 5px;
  padding: 1px 4px 0;
  font-family: var(--mono, ui-monospace, monospace);
  font-size: 0.72em;
  line-height: 1.35;
  letter-spacing: 0.08em;
  color: var(--ink-3);
  background: rgba(255, 255, 255, 0.09);
  border-radius: 2px;
  text-transform: uppercase;
}
.kf-row.mine .kf-bot { color: var(--ink-2); background: rgba(255, 255, 255, 0.14); }
/* THE WEAPON, now visible.
 *
 * It used to live only in a `title` attribute — a tooltip nobody can hover with
 * the pointer locked — so the middle of every killfeed row was a bare `›`. The
 * shell session landed the markup for a real weapon cell; this styles it, and
 * scripts/validate-ui-contract.mjs is how the two arrived in the same build
 * rather than as a row of unstyled text: it failed the moment .kf-weapon and
 * .kf-wname appeared in hud.js with no rule here.
 *
 * The name is set quiet and small on purpose. What a player reads off a killfeed
 * mid-fight is "who killed who" — the weapon is the answer to a question asked a
 * half-second later, so it must not compete with the two names either side of it. */
.kf-weapon {
  display: inline-flex; align-items: center; gap: 5px;
  flex: 0 0 auto;
  min-width: 0;
}
.kf-wname {
  font-family: var(--ui);
  font-size: 10.5px; font-weight: 600; letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--ink-3);
  white-space: nowrap;
  max-width: 11ch;
  overflow: hidden; text-overflow: ellipsis;
}
.kf-row.mine .kf-wname { color: rgba(255, 244, 222, 0.72); }
/* A knife or a suicide names itself in the glyph, so the word would be noise. */
.kf-wname:empty { display: none; }
/* Slides in from the left, matching the corner it lives in. */
@keyframes kfin { 0% { opacity: 0; transform: translateX(-14px); } 100% { opacity: 1; transform: none; } }

/* ==================== weapon pickup prompt ====================
 * Created by js/hud.js at runtime (`.pickup-prompt`) and appended to #hud. It had no
 * rules in this file at all, so it was rendering as unstyled inline text in the middle
 * of the HUD — found by scripts/validate-ui-contract.mjs the moment it landed, which
 * is the third time that check has caught a shell/UI seam in the same build.
 *
 * PLACEMENT: below centre, above the ammo block's reach. A contextual prompt has to be
 * findable without leaving the crosshair, and it must not sit ON the crosshair — you
 * are looking at the thing you are about to pick up. */
#hud .pickup-prompt {
  position: absolute;
  left: 50%; bottom: 26%;
  transform: translateX(-50%);
  display: flex; align-items: center; gap: 9px;
  padding: 8px 14px;
  max-width: min(70vw, 620px);
  background: rgba(6, 8, 10, 0.72);
  font-family: var(--ui);
  white-space: nowrap;
  animation: mcFade 160ms var(--ease) 1;
}
/* The verb carries the decision — PICK UP, SWAP FOR, TAKE AMMO are three different
 * actions and the difference is the whole point of the prompt. */
.pp-verb {
  font-size: 10.5px; font-weight: 700; letter-spacing: 0.16em;
  color: var(--ink-3);
  text-transform: uppercase;
}
/* The weapon is what you are reading, so it gets the display face and full ink. */
.pp-name {
  font-family: var(--display);
  font-size: 17px; letter-spacing: 0.06em; text-transform: uppercase;
  color: var(--ink);
  overflow: hidden; text-overflow: ellipsis; min-width: 0;
}
.pp-ammo {
  font-family: var(--num); font-variant-numeric: tabular-nums;
  font-size: 14px; font-weight: 700;
  color: var(--ink-2);
}
.pp-ammo i { font-style: normal; font-weight: 400; color: var(--ink-4); }
/* What you LOSE by taking it. Quiet, but present — a swap that silently drops your
 * primary is the prompt failing at its job. */
.pp-drop {
  font-size: 10.5px; letter-spacing: 0.1em;
  color: var(--red);
  text-transform: uppercase;
}

/* --- banners --- */
#banner {
  position: absolute; left: 50%; top: 26%; transform: translateX(-50%);
  font-size: clamp(26px, 4.2vw, 52px); letter-spacing: 0.1em; white-space: nowrap;
  text-transform: uppercase;
}
#banner.in { animation: bannerin 300ms var(--ease); }
@keyframes bannerin { 0% { opacity: 0; transform: translateX(-50%) scale(1.14); } 100% { opacity: 1; transform: translateX(-50%) scale(1); } }
#banner-sub {
  position: absolute; left: 50%; top: 35%; transform: translateX(-50%);
  font-family: var(--ui);
  font-size: 14px; color: var(--ink-2); letter-spacing: 0.08em; white-space: nowrap;
}
/* Out of the middle of the screen. It was 104px below the reticle, in the one part
 * of the frame the player is actually looking at. MW puts streak state bottom
 * right, above the ammo block. */
/* Sits ABOVE the weapon block, measured rather than guessed.
 *
 * `bottom: clamp(112px, 15vh, 140px)` was an estimate of how tall the block would
 * be, and at 15vh on a short window it landed *inside* it. The block's real height
 * is its own content: the reserved prompt row, the magazine figure, the ammo track,
 * the weapon line and the equipment row. Summed here once, with the block's own
 * bottom offset added, plus a gap. If a row is added to the block, this number is
 * the one place that has to change — and it is arithmetic, so it will be obvious.
 *
 *   18px  block bottom offset
 *   17px  reserved prompt row (--wb-prompt)
 *   ~46px magazine numeral at its clamp ceiling
 *   ~20px ammo track + margins
 *   ~34px weapon line (the badge sets this)
 *   ~20px equipment row + margin
 *   14px  breathing room
 */
#streak {
  position: absolute; right: 20px;
  bottom: calc(18px + 17px + 46px + 20px + 34px + 20px + 14px);
  text-align: right;
  font-size: 15px; color: var(--gold); letter-spacing: 0.14em;
  max-width: 46vw;
}
#streak.pop { animation: bannerin 260ms ease-out; }

/* --- health ---
 * MODERN WARFARE HAS NO HEALTH BAR, and this had a 220px one with the word HEALTH
 * under it — the single most "generic engine demo" element on the screen, and
 * redundant with three better signals that already existed.
 *
 * What is left is the low-health edge: a red rim that closes in under a third
 * health. `#health-fill` is kept as the element the HUD writes so the setter has
 * somewhere to go, but it draws the rim's opacity rather than a bar's width. */
#health { position: absolute; inset: 0; pointer-events: none; }
#health-fill {
  position: absolute; inset: 0; display: block;
  box-shadow: inset 0 0 90px 20px rgba(160, 24, 40, 0.5);
  opacity: 0;
  transition: opacity 220ms linear;
}
#health.hurt #health-fill { opacity: 1; }

/* ==================== the health readout ====================
 * A DEPARTURE FROM THE REFERENCE, requested directly. Call of Duty shows no
 * health number at all and communicates damage through the vignette and the
 * audio — which is what the rim above does. So this has to earn its place
 * without becoming the loudest thing on screen, and the way it does that is by
 * being ABSENT when it has nothing to say.
 *
 * Four states, and the middle two are the point:
 *   full        — not rendered. A permanently full bar is pure noise.
 *   holding     — damaged, regen has not started. STAY IN COVER.
 *   regenerating— climbing at 40 hp/s. You can move.
 *   critical    — under a third, and the rim is already screaming.
 *
 * Telling "holding" from "regenerating" apart is the actual value here, and it is
 * derived observationally in js/ui-boot.js rather than from a timer, so it stays
 * correct even if REGEN_DELAY changes.
 *
 * Bottom centre: the killfeed owns bottom-left, ammo owns bottom-right, score
 * owns top-centre and the radar top-left. This is the least contested space on
 * the screen and it sits in the lower periphery, where it can be registered
 * without leaving the crosshair.
 *
 * NO GOLD. Gold means "primary action / current selection" everywhere else in
 * this file, and health is neither — it is a state, so it uses the state colours.
 */
#hp-readout {
  position: absolute;
  left: 50%; bottom: 20px;
  transform: translateX(-50%);
  width: clamp(132px, 13vw, 186px);
  display: grid; gap: 5px;
  transition: opacity 260ms ease, transform 260ms var(--ease);
  will-change: opacity;
  /* A scrim, for the same reason the score plate needed one: over bright sand
   * near-white type at 19px has no contrast to spare. Soft-edged and unbounded so
   * it reads as the HUD sitting on the scene, not as a panel with a border. */
  padding: 7px 12px 9px;
  isolation: isolate;
}
#hp-readout::before {
  content: '';
  position: absolute; inset: -6px -10px -8px;
  background: radial-gradient(ellipse at center, rgba(4, 6, 8, 0.62) 0%, rgba(4, 6, 8, 0.34) 58%, transparent 100%);
  z-index: -1;
  pointer-events: none;
}
/* At full health it is gone — not faded, not a full bar. */
#hp-readout.is-full {
  opacity: 0;
  transform: translateX(-50%) translateY(6px);
  pointer-events: none;
}
.hp-head {
  display: flex; align-items: baseline; justify-content: space-between; gap: 10px;
  line-height: 1;
}
#hp-value {
  font-family: var(--num);
  font-variant-numeric: tabular-nums;
  font-size: 19px; font-weight: 700;
  color: var(--ink);
  /* Tabular AND fixed-width, so the numeral does not shuffle the state label
   * sideways as it counts 100 -> 98 -> 95 during a regen. */
  min-width: 3ch;
}
#hp-state {
  font-family: var(--ui);
  font-size: 9.5px; font-weight: 700; letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--ink-3);
  white-space: nowrap;
}
.hp-track {
  position: relative;
  height: 3px;
  background: rgba(0, 0, 0, 0.55);
  overflow: hidden;
}
/* THE GHOST: what is going to come back.
 * Sits under the fill and spans the whole track, so the gap between the fill's
 * edge and the ghost's edge IS the pending recovery — the reason a player can
 * tell "this will heal" from "this is gone" before it finishes healing. */
#hp-ghost {
  position: absolute; inset: 0;
  display: block;
  background: rgba(238, 241, 243, 0.16);
  transform-origin: left;
}
#hp-fill {
  position: absolute; inset: 0;
  display: block;
  background: var(--ink);
  transform-origin: left;
  /* Deliberately NOT smoothed beyond one frame's worth.
   * An eased fill would hide exactly the discrepancy this readout makes visible:
   * a red damage flash with no corresponding drop in the number. The value is
   * the instrument, so it must not be prettified. */
  transition: transform 90ms linear;
}
/* Regenerating: climbing. Green because --green already means confirmed /
 * recovering in this file, and a moving edge is what says "actively" rather than
 * "eventually". */
#hp-readout.is-regen #hp-fill { background: var(--green); }
#hp-readout.is-regen #hp-value { color: var(--green); }
#hp-readout.is-regen #hp-state { color: var(--green); }
#hp-readout.is-regen #hp-ghost { animation: hpGhost 1.1s ease-in-out infinite; }
@keyframes hpGhost { 50% { background: rgba(116, 209, 149, 0.3); } }
/* Holding: damaged and not yet recovering. The one state that means "do not move". */
#hp-readout.is-hold #hp-state { color: var(--ink-2); }
/* Critical. The rim and the blood are already loud, so this only needs to agree
 * with them rather than compete. */
#hp-readout.is-critical #hp-fill { background: var(--red); }
#hp-readout.is-critical #hp-value { color: var(--red); }
#hp-readout.is-critical #hp-state { color: var(--red); }
#hp-readout.is-critical .hp-track { box-shadow: 0 0 12px rgba(228, 89, 108, 0.45); }

/* ==================== weapon block ====================
 * THE BOTTOM-RIGHT STACK, AND WHY IT COLLIDED WITH THE KILL STREAK.
 *
 * The user: "the reload text overlaps with the kill streak text here btw."
 *
 * There is no separate reload element. js/hud.js reuses #low-ammo and swaps its
 * text to RELOAD (hud.js:246), and #low-ammo is the FIRST CHILD of a block that is
 * anchored to the BOTTOM and therefore grows UPWARD. So the instant that row
 * appears the block gets ~17px taller at the top, and its top edge climbs into
 * #streak, which was parked at a hand-tuned `bottom: clamp(112px, 15vh, 140px)` —
 * a guess at how tall the block would be.
 *
 * Two transient rows (the reload prompt, the streak) plus six permanent ones, and
 * no space reserved for either. A slot that is sometimes empty still needs its
 * space allocated, or it pushes into whatever is beside it.
 *
 * THE FIX IS ARITHMETIC, NOT A BIGGER GUESS. The block reserves the prompt row
 * whether or not it is occupied, so its height is CONSTANT, and #streak is placed
 * from that height rather than from a clamp that hopes. --wb-reserve is the one
 * number both rules read, so they cannot disagree.
 */
#weapon-block {
  --wb-edge: 20px;
  /* The prompt row's own height: 12px text + 5px margin, reserved always. */
  --wb-prompt: 17px;
  position: absolute; right: var(--wb-edge); bottom: 18px;
  text-align: right; min-width: 200px;
}
#low-ammo {
  font-family: var(--ui);
  font-size: 12px; font-weight: 600; letter-spacing: 0.18em; color: var(--red);
  margin-bottom: 5px; animation: pulse 0.9s steps(2) infinite;
}
/* SPACE HELD, CONTENT HIDDEN.
 *
 * js/hud.js toggles `.hidden` on this row, and `.hidden` is `display: none
 * !important` — which is what made the block change height. Overridden for this
 * one element only: it keeps its line box and stops being drawn, so RELOAD and
 * LOW AMMO appear and disappear without moving anything above them. The
 * `!important` is required to beat `.hidden`'s own, and the id keeps it specific
 * enough that nothing else inherits the behaviour. */
#low-ammo.hidden {
  display: block !important;
  visibility: hidden;
  animation: none;
}
#ammo { font-family: var(--num); font-weight: 700; font-variant-numeric: tabular-nums; line-height: 1; }
#ammo .mag { font-size: clamp(32px, 3.4vw, 46px); }
#ammo .sep { font-size: clamp(17px, 1.7vw, 22px); color: var(--ink-4); margin: 0 5px; }
#ammo .reserve { font-size: clamp(17px, 1.7vw, 22px); color: var(--ink-2); }
#ammo.low .mag { color: var(--gold); }
#ammo.empty .mag { color: var(--red); }
#ammo.tick { animation: ammotick 110ms ease-out; }
@keyframes ammotick { 0% { transform: translateY(-2px); } 100% { transform: none; } }
.ammo-track { height: 2px; background: rgba(255, 255, 255, 0.14); margin: 6px 0 6px; }
#ammo-fill { display: block; height: 100%; width: 100%; transform-origin: right; background: var(--ink-2); transition: transform 90ms linear; }
#weapon-line { display: flex; justify-content: flex-end; gap: 10px; align-items: center; }
/* The class silhouette in its plate, as in the reference. */
#weapon-badge {
  display: inline-flex; align-items: center; justify-content: center;
  width: 34px; height: 34px; border-radius: 50%;
  background: rgba(255, 255, 255, 0.08);
  box-shadow: inset 0 0 0 1px rgba(238, 241, 243, 0.14);
  color: var(--ink); order: 3;
  flex: 0 0 auto;
}
#weapon-badge svg { display: block; }
/* js/hud.js fills this with the class silhouette when a weapon is equipped. Until
 * then it was an empty glassy 34px circle parked in the corner of the HUD —
 * decoration that looked like a component waiting for data. No content, no chip. */
#weapon-badge:empty { display: none; }
#weapon-name { font-size: 17px; letter-spacing: 0.08em; text-transform: uppercase; }
#firemode { font-family: var(--ui); font-size: 10.5px; font-weight: 600; letter-spacing: 0.16em; color: var(--ink-3); }
/* The last row in the block, so it is the one nearest the screen edge. It was
 * rendering clipped and faded at the bottom: `line-height` was inherited from the
 * HUD's default and a 10.5px row with descenders had no room under it, so the
 * bottom of FRAG/FLASH was being cut by the block's own edge. An explicit
 * line-height and a little padding give the row its own space. */
#equipment {
  display: flex; justify-content: flex-end; gap: 14px; margin-top: 7px;
  font-family: var(--ui); font-size: 10.5px; font-weight: 600; letter-spacing: 0.12em; color: var(--ink-2);
  line-height: 1.5;
  padding-bottom: 2px;
}
/* A SPENT LETHAL IS NOT A LIVE ROW.
 *
 * js/hud.js writes `FRAG ×0` and adds `.spent`, and the old treatment — dim ink
 * plus `line-through` — read as a live entry with a line drawn over it. At 10.5px a
 * strikethrough is mud, and mud that still looks available is worse than nothing:
 * the player has to read the numeral to learn they cannot throw it.
 *
 * The text belongs to hud.js so it cannot be removed from here. What it CAN do is
 * make depletion unmistakable at a glance rather than on inspection: struck out is
 * gone, dropped to a quarter opacity, and no longer competing with the live one
 * beside it. */
#equipment .spent {
  color: var(--ink-4);
  text-decoration: none;
  opacity: 0.42;
}

/* --- spawn-in --- */
#spawnin { position: absolute; inset: 0; }
/* --- LETTERBOX BARS: REMOVED ---
 *
 * There were two 9vh bars, top and bottom, on the theory that a spawn-in is a
 * cinematic beat and the frame should say so. The user played it: "get rid of the
 * cinematic borders on the spawn in animation that just looks cheap and stupid."
 *
 * They are right about the effect even though the intent was sound — a hard bar
 * over a real 3D frame reads as a letterbox laid ON the game rather than as a
 * camera doing something, and it hid the top of the map during the one beat that
 * exists to show you the map. The countdown, the mode, the team and the map name
 * all stay; the spawn-in is now full-frame, edge to edge.
 *
 * Deleted rather than flagged behind an option, same as the compass strip, so it
 * cannot return by accident. If a cinematic frame is ever wanted again it should
 * come from the camera and the post stack, not from two divs.
 */
.si-frame { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); text-align: center; }
/* Nothing behind the countdown now, so it needs to carry its own legibility
 * against a bright sky. A soft radial scrim under the text block only — it
 * cannot letterbox anything because it has no edges. */
.si-frame::before {
  content: ''; position: absolute; left: 50%; top: 50%;
  width: 150%; height: 260%;
  transform: translate(-50%, -50%);
  background: radial-gradient(ellipse at center, rgba(4, 6, 8, 0.58) 0%, rgba(4, 6, 8, 0.28) 45%, transparent 72%);
  z-index: -1;
  pointer-events: none;
}
#spawnin-mode { font-family: var(--ui); font-size: 12px; font-weight: 600; letter-spacing: 0.34em; color: var(--ink-2); }
#spawnin-team { font-size: clamp(24px, 2.8vw, 32px); letter-spacing: 0.16em; margin-top: 4px; text-transform: uppercase; }
#spawnin-count {
  font-family: var(--num); font-weight: 700;
  font-size: clamp(72px, 11vw, 150px); line-height: 0.9; margin: 8px 0;
  font-variant-numeric: tabular-nums;
  text-shadow: 0 0 60px rgba(0, 0, 0, 0.85);
}
#spawnin-count.tick { animation: sitick 420ms var(--ease); }
@keyframes sitick {
  0% { opacity: 0.15; transform: scale(1.5); letter-spacing: 0.2em; }
  100% { opacity: 1; transform: scale(1); letter-spacing: 0; }
}
#spawnin-map { font-family: var(--ui); font-size: 13px; font-weight: 600; letter-spacing: 0.34em; color: var(--gold); }

/* --- respawn ---
 * Bottom of the frame while a killcam is up, not dead centre over it. */
#respawn { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); text-align: center; }
#respawn.over-killcam { top: auto; bottom: 58px; transform: translateX(-50%); }
#respawn.over-killcam #respawn-hint { display: none; }
#respawn.over-killcam #respawn-count { font-size: 28px; opacity: 0.85; }
#respawn-hint { font-family: var(--ui); font-size: 12px; font-weight: 600; letter-spacing: 0.28em; color: var(--ink-2); }
/* The skip prompt lives with the countdown so it is present on the DEATH CAMERA as
 * well as the killcam — the killcam had its own copy and the death camera had none. */
#respawn-skip { margin-top: 8px; font-family: var(--ui); font-size: 11px; letter-spacing: 0.06em; color: var(--ink-3); }
#respawn.over-killcam #respawn-skip { display: none; }   /* .kc-skip already says it */
#respawn-count { font-family: var(--num); font-weight: 700; font-size: 68px; line-height: 1; font-variant-numeric: tabular-nums; }
#death-note { font-family: var(--ui); font-size: 12.5px; color: var(--ink-3); margin-top: 8px; }

/* --- death panel ---
 * `100 | 2` is the useful number: it tells the player they were two-shot, which is
 * the difference between "I was outplayed" and "that gun two-shots from there".
 * Right-hand side rather than centred: the death camera is looking at your body in
 * the middle of the frame, and the panel was sitting on top of it. */
#death-panel {
  position: absolute; right: 20px; top: 50%; transform: translateY(-50%);
  width: min(360px, 42vw); min-width: 260px;
  background: rgba(6, 8, 10, 0.9);
  padding: 0 0 14px;
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
/* The header band carries the danger colour as TYPE on a tinted bar. */
.dp-head {
  font-family: var(--ui);
  font-size: 9.5px; font-weight: 600; letter-spacing: 0.22em;
  color: #ffd8de;
  background: rgba(122, 22, 34, 0.85);
  margin: 0 0 8px;
  padding: 7px 14px;
  text-transform: uppercase;
}
/* The second and any later head ("AFFECTED BY") is a quiet sub-label, not a
 * repeat of the red band — one band per panel or it stops meaning "top". */
.dp-head ~ .dp-head { background: transparent; color: var(--ink-3); padding: 12px 14px 4px; }
.dp-row {
  display: grid; grid-template-columns: 22px minmax(0, 1fr) auto 70px;
  gap: 9px; align-items: center; padding: 6px 14px;
  border-bottom: 1px solid var(--rule);
  font-size: 15px;
}
.dp-row.dim { grid-template-columns: minmax(0, 1fr); color: var(--ink-2); font-size: 12.5px; font-family: var(--ui); }
.dp-icon { color: var(--ink-2); display: flex; }
/* hud.js sets `color` inline on .dp-name from the enemy role colour. */
.dp-name {
  font-family: var(--ui); font-weight: 600;
  font-size: 14px; letter-spacing: 0.05em; text-transform: uppercase;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.dp-weapon { font-family: var(--ui); font-size: 10.5px; font-weight: 600; letter-spacing: 0.1em; color: var(--ink-3); }
.dp-dmg { font-family: var(--num); font-weight: 700; font-variant-numeric: tabular-nums; text-align: right; font-size: 16px; }
.dp-dmg i { color: var(--ink-4); font-style: normal; margin: 0 4px; font-weight: 400; }
/* The 14px matches .dp-row and .dp-head, so the panel has ONE left edge. It was
 * inheriting nothing and starting 14px further left than every row above it. */
.dp-killed {
  margin-top: 10px; padding: 0 14px;
  font-family: var(--ui); font-size: 12.5px; letter-spacing: 0.06em; color: var(--ink-2);
}
.dp-killed b { font-weight: 600; color: var(--ink); }

/* ==================== killcam ====================
 * "there's no killcam HUD". There was chrome, but it said one thing — the
 * killer's name — in the middle of a gradient, and nothing told you whose death
 * you were watching, how long it would last, or that SPACE would end it in a way
 * you would notice.
 *
 * Structure now, left to right along one top strip: the KILLCAM tag, whose
 * death it is, then the killer and what they used, then the time remaining. A
 * progress bar under it, and the skip affordance as a real key cap at the
 * bottom. #killcam-victim degrades to nothing when the shell has not filled it,
 * so this is safe to ship ahead of that field arriving.
 */
#killcam { position: absolute; inset: 0; }
/* A hard frame, so there is never a second in which the player cannot tell
 * whether they are watching a replay or playing. */
#killcam::before {
  content: ''; position: absolute; inset: 0;
  box-shadow: inset 0 0 0 2px rgba(224, 164, 55, 0.26), inset 0 0 140px rgba(0, 0, 0, 0.6);
}
.kc-top {
  position: absolute; top: 0; left: 0; right: 0;
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  align-items: center;
  gap: clamp(10px, 2vw, 22px);
  padding: 14px clamp(16px, 3vw, 34px) 18px;
  background: linear-gradient(180deg, rgba(6, 8, 10, 0.9) 0%, rgba(6, 8, 10, 0.55) 55%, transparent 100%);
}
.kc-tag {
  font-family: var(--ui);
  font-size: 11px; font-weight: 700; letter-spacing: 0.3em;
  color: #14100a;
  background: var(--gold);
  padding: 5px 10px;
  white-space: nowrap;
}
/* The tag and whose death it is, stacked as ONE grid item.
 *
 * #killcam-victim was a sibling of .kc-top, and both are absolutely positioned
 * inside #killcam, so it landed at top:0 left:0 underneath the strip and was
 * clipped to a sliver. It belongs in the strip's first column. */
.kc-id { display: grid; gap: 4px; justify-items: start; min-width: 0; }
/* Whose death. Quiet, because it is context, not news. */
.kc-victim {
  font-family: var(--ui);
  font-size: 10.5px; font-weight: 600; letter-spacing: 0.14em;
  color: var(--ink-3);
  text-transform: uppercase;
  white-space: nowrap;
}
.kc-victim:empty { display: none; }
.kc-victim b { color: var(--ink-2); font-weight: 600; }
/* The one thing the player wants: who, and with what. */
.kc-line {
  font-size: clamp(16px, 1.9vw, 23px);
  letter-spacing: 0.06em;
  color: var(--ink-3);
  text-transform: uppercase;
  min-width: 0;
  display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap;
  justify-content: center;
}
.kc-line b { color: var(--ink); font-weight: 400; }
.kc-weapon {
  font-family: var(--ui);
  font-size: 10.5px; font-weight: 600; letter-spacing: 0.16em;
  color: var(--ink-2);
  background: rgba(255, 255, 255, 0.07);
  padding: 4px 9px;
  white-space: nowrap;
  justify-self: end;
}
.kc-weapon:empty { display: none; }
.kc-bar { position: absolute; top: 0; left: 0; right: 0; height: 2px; background: rgba(255, 255, 255, 0.12); }
.kc-bar i { display: block; height: 100%; background: var(--gold); transform-origin: left; transform: scaleX(0); }
.kc-skip {
  position: absolute; bottom: 24px; left: 50%; transform: translateX(-50%);
  font-family: var(--ui);
  font-size: 11.5px; letter-spacing: 0.1em; color: var(--ink-2);
  display: flex; align-items: center; gap: 8px;
  background: rgba(6, 8, 10, 0.6);
  padding: 7px 14px;
  white-space: nowrap;
}

/* ==================== the match-ended beat ====================
 * "right after ur final kill everyone should stop moving and it should be clear
 * the game is over and have a bit of a game ended screen before showing final
 * killcam too to everyone."
 *
 * Drawn over a world that has genuinely stopped, and it has one job: make the
 * stop legible so a frozen frame cannot be mistaken for a hang. So the treatment
 * is a full-bleed dim and TYPE — no plate, no rule, no accent edge. The dim is
 * what says "this is not the game any more"; the type says what happened.
 *
 * NO z-index. It sits inside #hud's own stacking context after #killcam in source
 * order, and source order is the entire relationship; a literal here is how
 * --z-fx once came to equal --z-modal.
 *
 * NO ACCENT SURFACE. --gold is "primary action" and "currently selected" and is
 * already spent to its budget; a result is neither of those things. VICTORY and
 * DEFEAT are told apart by the banner and the board that follow, which is where
 * that distinction is already made as data.
 *
 * Every rule is qualified by #match-ended so a descendant selector elsewhere
 * cannot outrank a bare class on these elements. */
#match-ended {
  position: absolute; inset: 0;
  display: grid; place-items: center;
  background: rgba(4, 6, 8, 0.62);
  animation: me-in 220ms var(--ease) both;
}
#match-ended .me-plate {
  display: flex; flex-direction: column; align-items: center;
  gap: clamp(6px, 1vw, 12px);
  padding: 0 24px;
  text-align: center;
}
/* The label, not the headline. It states the FACT — the match is over — while the
 * line under it states the outcome, and keeping them apart is why the beat reads
 * as a full stop rather than as an early scoreboard. */
#match-ended .me-tag {
  font-family: var(--ui);
  font-size: clamp(10.5px, 1.1vw, 13px);
  letter-spacing: 0.34em; text-indent: 0.34em;
  color: var(--ink-3);
}
#match-ended .me-title {
  font-family: var(--display);
  font-size: clamp(40px, 6.4vw, 92px);
  line-height: 0.92; letter-spacing: 0.02em;
  color: var(--ink);
}
#match-ended .me-sub {
  font-family: var(--ui);
  font-size: clamp(12px, 1.35vw, 16px);
  letter-spacing: 0.08em;
  color: var(--ink-2);
}
@keyframes me-in { from { opacity: 0; } to { opacity: 1; } }

/* ==================== scoreboards ====================
 * The element the user named. It was a 1px box around a grid whose only
 * emphasis was a 2-3px inset edge stripe per row, in three different colours,
 * on top of a team header wearing a fourth.
 *
 * A Call of Duty end-of-match board is almost entirely TYPE AND SPACE, so this
 * one is too:
 *   - the header row is a filled band, not a bordered strip
 *   - every numeric column is tabular so digits line up down the column
 *   - SCORE is the column that matters and is the only one at full weight
 *   - "you" is a FILLED ROW, not an edge stripe
 *   - teams are separated by a coloured heading and a gap, not by a rule
 * hud.js still writes `style="border-color:…;color:…"` on .sb-team; the colour
 * is what is used and the border-color lands on an element with no border,
 * which is inert.
 */
#scoreboard, #endboard {
  position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
  width: min(880px, calc(100vw - 40px));
  max-height: calc(100vh - 48px);
  overflow-y: auto;
  background: rgba(6, 8, 10, 0.92);
  padding: 0;
  box-shadow: 0 40px 120px rgba(0, 0, 0, 0.7), inset 0 1px 0 rgba(255, 255, 255, 0.07);
}
/* Liquid glass, per the user: see the match through it, blurred.
 *
 * Three layers do the work and all three are needed. `backdrop-filter` blurs the live
 * scene behind the panel — that is what makes it read as glass rather than as a dark
 * rectangle. A low-alpha fill keeps the type legible over whatever the blur happens
 * to land on, since a scoreboard over bright sky and one over a shadowed container
 * must both be readable. And a 1px inner highlight on the top edge gives the sheet a
 * thickness; without it the blur looks like a bug rather than a material.
 *
 * `saturate(1.15)` is deliberate: a straight blur of a desert scene goes muddy, and a
 * touch of saturation keeps the colour behind the glass alive.
 *
 * The alpha is 0.62 rather than lower because this panel carries a dense numeric
 * table. Glass is a finish, not an excuse for an unreadable scoreboard — if the blur
 * ever fights the digits, the digits win. */
/* THE DIGITS WON — applying the rule the note above sets.
 *
 * At 0.62 alpha a critic measured the panel doing exactly what that note warns
 * against: the scene bled through hard enough that the plate's own colour drifted
 * from blue-grey at the title to brown at the buttons, and per-row text contrast
 * changed as you read down the table. `saturate(1.15)` made it worse in the one
 * place it mattered, pushing the background UP in saturation directly behind the
 * numerals.
 *
 * So: still glass — 0.8 over a 30px blur is unmistakably a translucent sheet with
 * the match moving behind it — but a sheet thick enough that a row's legibility
 * does not depend on what happens to be behind that row. Saturation left at 1.0;
 * the blur alone keeps the scene alive without tinting the type. */
#endboard, #scoreboard {
  background: rgba(8, 11, 14, 0.8);
  -webkit-backdrop-filter: blur(30px);
  backdrop-filter: blur(30px);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.16),
    inset 0 0 0 1px rgba(255, 255, 255, 0.06),
    0 18px 48px rgba(0, 0, 0, 0.45);
}
#endboard { pointer-events: auto; }

/* Safari and Firefox without backdrop-filter fall back to a solid panel rather than
 * a translucent one nobody can read through. */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  #endboard, #scoreboard { background: rgba(6, 8, 10, 0.95); }
}
.sb-title {
  padding: clamp(16px, 2.4vw, 24px) clamp(16px, 2.4vw, 26px) 0;
}
#scoreboard h3, #endboard h2 {
  margin: 0;
  font-family: var(--display);
  letter-spacing: 0.07em;
  text-transform: uppercase;
  color: var(--ink);
}
#scoreboard h3 { font-size: clamp(17px, 1.8vw, 22px); }
#endboard h2 { font-size: clamp(28px, 3.6vw, 46px); line-height: 0.96; }
#endboard-sub {
  margin: 6px 0 0;
  color: var(--ink-2); font-size: 13.5px; letter-spacing: 0.02em;
}
.sb-head, .sb-row {
  display: grid;
  gap: clamp(4px, 0.8vw, 10px);
  align-items: center;
  padding: 0 clamp(16px, 2.4vw, 26px);
}
/* Live: Score / Kills / Deaths / Assists. End: the full accounting. Two column
 * sets, and the grid template is the only thing that differs. */
.sb-head.live, #scoreboard-body.live .sb-row { grid-template-columns: minmax(0, 1fr) 64px 48px 48px 52px; }
.sb-head.full, #endboard-body.full .sb-row { grid-template-columns: minmax(0, 1fr) 62px 46px 46px 50px 56px 46px; }
.sb-head {
  font-family: var(--ui);
  font-size: 9.5px; font-weight: 700; letter-spacing: 0.18em;
  color: var(--ink-3);
  background: rgba(255, 255, 255, 0.045);
  padding-top: 9px; padding-bottom: 9px;
  margin-top: clamp(14px, 2vw, 20px);
  text-transform: uppercase;
}
.sb-head span:not(:first-child), .sb-row span:not(:first-child) { text-align: right; }
.sb-row {
  font-family: var(--ui);
  font-size: 15px;
  padding-top: 9px; padding-bottom: 9px;
  border-bottom: 1px solid var(--rule);
}
.sb-name {
  font-weight: 600; letter-spacing: 0.05em; text-transform: uppercase;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  color: var(--ink);
}
/* Tabular on EVERY numeric cell. A board whose digits change width between rows
 * cannot be read down a column, and it is one property. */
.sb-n, .sb-row span:not(:first-child) {
  font-family: var(--num);
  font-variant-numeric: tabular-nums;
  font-size: 14px;
  color: var(--ink-2);
  font-feature-settings: 'tnum' 1;
}
/* SCORE is the ranking column, so it is the only one at full weight and full
 * ink. Everything else is a supporting figure. That is the hierarchy the edge
 * stripes were standing in for. */
.sb-row .sb-n:first-of-type { color: var(--ink); font-weight: 700; font-size: 15.5px; }
/* You. A filled row — the strongest, cheapest emphasis there is. */
.sb-row.me { background: rgba(224, 164, 55, 0.15); }
.sb-row.me .sb-name { color: var(--gold); }
.sb-row.dead .sb-name { opacity: 0.45; }
.sb-row.dead .sb-n { opacity: 0.7; }
/* Team grouping: a coloured heading and a gap above it. No rule, no left bar.
 * `color` arrives inline from hud.js. */
.sb-team {
  margin: clamp(16px, 2.2vw, 22px) 0 0;
  padding: 0 clamp(16px, 2.4vw, 26px) 8px;
  font-family: var(--ui);
  font-size: 12px; font-weight: 700; letter-spacing: 0.2em;
  text-transform: uppercase;
  display: flex; align-items: baseline; gap: 12px;
}
.sb-team-role { font-style: normal; font-size: 9px; font-weight: 600; letter-spacing: 0.16em; opacity: 0.72; }
.sb-bot {
  font-style: normal; font-size: 8.5px; font-weight: 700; letter-spacing: 0.14em;
  color: var(--ink-3); margin-left: 8px; vertical-align: 1.5px;
}
.sb-foot {
  margin: 0;
  padding: 14px clamp(16px, 2.4vw, 26px) clamp(16px, 2.4vw, 20px);
  font-family: var(--ui);
  font-size: 11.5px; color: var(--ink-3); letter-spacing: 0.04em;
  display: flex; align-items: center; gap: 8px;
}
.eb-actions {
  display: flex; gap: 12px; flex-wrap: wrap;
  padding: clamp(18px, 2.4vw, 24px) clamp(16px, 2.4vw, 26px);
}

#net-notice, #warn-notice {
  position: absolute; left: 50%; transform: translateX(-50%);
  font-family: var(--ui);
  font-size: 11.5px; font-weight: 600; letter-spacing: 0.1em; padding: 7px 14px;
  background: rgba(6, 8, 10, 0.92);
  max-width: calc(100vw - 40px);
  text-align: center;
}
#net-notice { bottom: 96px; color: var(--blue); }
#warn-notice { bottom: 66px; color: var(--gold); }

/* Everything describing YOUR body and YOUR gun goes away for a replay or a final
 * board. See HUD.setCombatChrome — during someone else's point of view every one of
 * these elements is describing a corpse. */
.chrome-off { opacity: 0 !important; pointer-events: none !important; }

/* ============================== responsive ==============================
 * Structural, not fluid-typographic. Four things change with width: the menu
 * stops being two columns, the loadout table sheds columns, the lobby stacks,
 * and the HUD's fixed corners tighten. Verified at 3840/2560/1920/1440/1280/
 * 1024/900/768/640/480/360 and at 1280x620 (a short window).
 */

/* Ultrawide: stop the content running to the edges of a 34" panel — but the
 * attribution BAR still spans the full width. Capping the bar itself left a
 * 1560px strip floating in the middle of a 2560px screen with the page showing
 * either side, which reads as a broken element rather than as a footer. The bar
 * is full-bleed; its CONTENT is what gets centred, via padding. */
@media (min-width: 2000px) {
  .menu-grid, .sheet { width: min(1560px, 100%); }
  .legal { width: 100%; padding-inline: calc((100% - 1560px) / 2 + clamp(22px, 5vw, 78px)); }
}

/* Laptop / small desktop: the loadout table cannot hold five readable columns
 * much below here, so it becomes three, then two. */
@media (max-width: 1180px) {
  #class-cards { grid-template-columns: repeat(3, minmax(0, 1fr)); }
  .card:nth-child(3n) { border-right: 0; }
  .card:nth-child(-n+3) { border-bottom: 1px solid var(--rule); }
  .card-role { min-height: 4.4em; }
}

/* The awkward middle: the menu's two columns no longer both fit. Identity and
 * actions stay left; the deployment panel drops beneath them and goes wide. */
@media (max-width: 1040px) {
  .menu-grid {
    grid-template-columns: minmax(0, 1fr);
    grid-template-areas: 'brand' 'nav' 'setup';
    align-content: start;
    gap: clamp(20px, 3.4vh, 34px);
    padding-top: clamp(24px, 5vh, 48px);
  }
  /* The identity comes down once it is no longer sharing the row with the
   * deployment panel, so the stacked layout has a chance of fitting a laptop
   * viewport without scrolling to reach the attribution bar. */
  .brand h1 { font-size: clamp(40px, 6.6vw, 76px); }
  .tagline { margin-top: 10px; }
  .menu-main { max-width: 520px; }
  .menu-setup { max-width: 620px; }
  /* Clearance for the now-sticky attribution bar. Without it the last field
   * scrolls underneath the bar and can never be brought fully into view. */
  .menu-grid { padding-bottom: 124px; }
  /* Exactly two columns, so the four fields make a tidy 2x2. `auto-fit` fitted
   * three and left BOT SKILL orphaned on a row of its own. */
  .menu-setup .menu-setup-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 15px clamp(14px, 2.5vw, 26px); }
  .legal {
    grid-template-columns: minmax(0, 1fr);
    gap: 10px;
  }
  .controls-legend { text-align: left; line-height: 1.95; }
}

/* THE LOBBY'S OWN BREAKPOINTS, structural and separate from the menu's.
 *
 * Three rails need ~830px of content before the middle one stops being able to
 * hold a four-name roster with a side tag and a class on each row. Below that the
 * kit rail — the one a player acts on last — drops under the squad and goes wide,
 * which keeps DEPLOYMENT and SQUAD side by side for as long as possible. Below
 * ~820 everything stacks in reading order: what am I playing, who is here, what
 * am I taking. */
@media (max-width: 1220px) {
  .lobby-wrap {
    grid-template-columns: minmax(230px, 288px) minmax(0, 1fr);
    grid-template-areas: 'brief squad' 'brief kit';
    /* `stretch`, NOT `start`, and this cost a defect to learn.
     *
     * With `align-items: start` a rail is sized to its own content, and a box whose
     * height is auto never scrolls however `overflow-y` is set — it just grows. So
     * the deployment rail ran past the bottom of the sheet and painted underneath
     * the sticky foot, with `scrollHeight === clientHeight` and every action button
     * still in view, because the SCREEN was fine and the RAIL was not.
     *
     * It survived because the reachability check gated 1280, 1600 and 1920 — three
     * widths, all of them the three-column layout. A breakpoint nothing measures is
     * a layout nobody has seen. The check now gates this branch too. */
    align-items: stretch;
  }
  .lobby-brief { grid-area: brief; }
  .lobby-main  { grid-area: squad; }
  .lobby-kit   { grid-area: kit; }
  /* Two columns of fields once it is a wide band rather than a narrow rail. */
  .lobby-kit { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); align-content: start; }
  .lobby-kit > .panel-label, .lobby-kit > .lobby-camo, .lobby-kit > .lobby-equip,
  .lobby-kit > .gs-note { grid-column: 1 / -1; }
}
@media (max-width: 820px) {
  .lobby-wrap {
    grid-template-columns: minmax(0, 1fr);
    grid-template-areas: 'brief' 'squad' 'kit';
    height: auto;
  }
  .lobby-kit { display: flex; }
  .lobby-invite { width: 100%; }
  /* Stacked, the SCREEN scrolls and the rails do not — three independent scroll
   * regions in one column is a thing nobody can operate. The foot is still
   * sticky, so the action row is still reachable without arithmetic. */
  .lobby-sheet { height: auto; min-height: 100%; grid-template-rows: auto auto auto; align-content: start; }
  .lobby-rail { overflow: visible; }
  /* CLEARANCE FOR THE STICKY FOOT. A sticky, opaque action row over a scrolling
   * page hides whatever is under it — and at the scroll END there is nothing below
   * to keep scrolling, so the last band is hidden permanently rather than
   * temporarily. Measured: the finish swatches and the kit note sat behind it.
   *
   * The value is the foot's own height (two 55px buttons in 14px padding) plus a
   * line of air. It is a constant because the foot's height is not expressible in
   * CSS from here; the check scrolls to the bottom and asserts the clearance
   * empirically, so a foot that grows makes the check fail rather than making the
   * page silently swallow a band again. */
  .lobby-sheet > .sheet-body { padding-bottom: 96px; }
}

@media (max-width: 860px) {
  #class-cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .card { border-right: 1px solid var(--rule); border-bottom: 1px solid var(--rule); }
  .card:nth-child(2n) { border-right: 0; }
  /* Five cards in two columns always leaves one alone on the last row, and it was
   * keeping a column divider with nothing on the other side of it — a rule
   * separating a card from empty space. A divider has to divide something. */
  .card:last-child { border-right: 0; border-bottom: 0; }
  .card-role { min-height: 0; }
  .opt-grid { grid-template-columns: minmax(0, 1fr); }
  .sheet-head { align-items: flex-start; }
}

/* Small window / phone-shaped viewport. Nobody plays here — the device gate
 * sees to that — but a menu that breaks is still a broken menu, and a desktop
 * user with a narrow window is a real case. */
@media (max-width: 620px) {
  #class-cards { grid-template-columns: minmax(0, 1fr); }
  .card { border-right: 0; }
  .card:last-child { border-bottom: 0; }
  .brand h1 { font-size: clamp(40px, 13vw, 64px); }
  .menu-main .btn.primary { font-size: 18px; }
  .modal { padding: 4vh 5vw; }
  .lobby-code { flex-direction: column; align-items: stretch; }
  .load-top { flex-direction: column; align-items: flex-start; gap: 6px; }
  /* The attribution bar wraps to two rows down here, so the toast — which the
   * device gate uses for its cramped-window notice — has to clear it. */
  #toast { bottom: 124px; left: 12px; right: 12px; transform: none; max-width: none; }
}

/* A SHORT window. The menu was vertically centred with an absolutely positioned
 * footer, so at 620px tall the attribution sat on top of the buttons. The grid
 * plus a static footer fixes it structurally; this only relaxes the padding. */
@media (max-height: 700px) {
  .menu-grid { align-content: start; padding-top: clamp(18px, 3vh, 32px); gap: clamp(14px, 2.4vh, 26px); }
  .brand h1 { font-size: clamp(38px, 6.4vw, 82px); }
  .tagline { margin-top: 8px; }
  .sheet { gap: 16px; padding-top: 20px; padding-bottom: 20px; }
  #loading { align-content: center; }
}
@media (max-height: 560px) {
  .controls-legend { display: none; }
  .legal { padding-top: 10px; padding-bottom: 10px; }
}

/* HUD corners tighten so nothing is clipped or overlapping on a small window. */
@media (max-width: 900px) {
  /* `left` and `max-width` moved to the stack with the position they belong to —
   * setting them on the children did nothing once the children stopped being
   * positioned, and would have left the feed at its desktop width on a narrow
   * window. `font-size` stays on the feed, because that is the feed's own look. */
  #killfeed { font-size: 13px; }
  #feed-stack { left: 14px; max-width: 58vw; }
  #minimap { left: 14px; top: 14px; }
  #weapon-block, #streak { right: 14px; }
  #death-panel { right: 14px; width: min(300px, 52vw); min-width: 0; }
  .kc-top { grid-template-columns: auto minmax(0, 1fr); row-gap: 8px; }
  .kc-weapon { grid-column: 2; justify-self: start; }
}
@media (max-width: 620px) {
  #score-header { top: 8px; }
  #feed-stack { max-width: 72vw; }
  #weapon-block { min-width: 0; }
  .sb-head.full, #endboard-body.full .sb-row { grid-template-columns: minmax(0, 1fr) 52px 40px 40px 44px 48px 40px; font-size: 13px; }
  .sb-head.live, #scoreboard-body.live .sb-row { grid-template-columns: minmax(0, 1fr) 54px 42px 42px 46px; }
  /* The end board's seven headers did not fit their own columns down here —
   * "DEATHS" needed 59px in a 40px track and "ASSIST" 54px in 44px, so they
   * overflowed and OVERPRINTED each other: the results screen rendered
   * "KILLSDEATHASSIST" with "ASS" superimposed on "DEATHS". Truncation is not
   * enough for a header, because a clipped header labels the wrong thing — so
   * they shrink and stop tracking, and the columns they label are the numbers,
   * which are still tabular and still aligned. */
  .sb-head { font-size: 8px; letter-spacing: 0.02em; }
  .sb-head span { overflow: hidden; text-overflow: clip; white-space: nowrap; min-width: 0; }
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
  }
  /* The reveal-style animations end at their final state, so killing the
   * duration leaves them visible rather than blank. Verified for .kf-row,
   * .bl-spat, #banner and .kc-pop, whose 100% keyframes are all opacity 1 —
   * except .kc-pop and .bl-spat.out which END hidden by design, and those are
   * removed from the DOM by hud.js either way. */
}

/* ---- damage feedback overlays ----------------------------------------------
 * js/fx.js drives both of these by writing `style.opacity` (and `style.background`
 * on the flash) directly. They had gone missing from index.html entirely, so every
 * write hit a null and the whole damage-feedback channel was silently inert.
 *
 * Restoring the elements is only half of it — they also need a resting state, or the
 * first write leaves an opaque sheet over the game. Both start at opacity 0 and
 * transition, so fx.js's "set to 1, then set back to 0 after 180ms" reads as a pulse
 * rather than a flicker.
 *
 * Neither may ever obscure the crosshair. The vignette is a radial gradient with a
 * clear centre for that reason; the flash is deliberately brief and low-alpha rather
 * than a full white-out. */
#dmg-vignette, #screen-flash {
  position: fixed; inset: 0;
  pointer-events: none;
  opacity: 0;
  z-index: var(--z-fx);
}
/* THESE TWO DESCRIBE YOUR BODY, so they must not survive onto someone else's
 * point of view — and they are the one pair that `HUD.setCombatChrome` cannot
 * reach, because js/fx.js owns them and they are absent from hud.js's element
 * table. The rest of the player chrome gets `.chrome-off`; these get this.
 *
 * It matters more than tidiness. The user has twice reported a red flash in a
 * position where nothing should have been able to hit them, so either the overlay
 * fires without a damage event or it fails to clear. Whichever it turns out to be,
 * a vignette that is stuck on cannot then bleed across a killcam or the final
 * board — the failure stays contained to the frame that caused it, and the health
 * readout stays readable while it is being diagnosed. */
#hud:has(#killcam:not(.hidden)) #dmg-vignette,
#hud:has(#killcam:not(.hidden)) #screen-flash,
#hud:has(#endboard:not(.hidden)) #dmg-vignette,
#hud:has(#endboard:not(.hidden)) #screen-flash,
#hud:has(#spawnin:not(.hidden)) #dmg-vignette,
#hud:has(#spawnin:not(.hidden)) #screen-flash {
  opacity: 0 !important;
}
#dmg-vignette {
  background: radial-gradient(ellipse at center,
    rgba(120, 8, 8, 0) 42%,
    rgba(120, 8, 8, 0.28) 78%,
    rgba(90, 4, 4, 0.55) 100%);
  transition: opacity 180ms ease-out;
}
#screen-flash {
  /* fx.js sets `background` per call — this is only the fallback. */
  background: rgba(255, 255, 255, 0.5);
  transition: opacity 120ms linear;
  mix-blend-mode: screen;
}


/* ==================== arsenal search ====================
 * Composes with the grid rather than replacing it: the filter removes ENTRIES and
 * then removes any group left empty, so the category headings stay legible while
 * filtering instead of collapsing into one undifferentiated list. */
.ars-head {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: 16px; flex-wrap: wrap;
  margin-bottom: 10px;
}
.ars-head .panel-label { margin: 0; }
.ars-search { display: block; min-width: 0; flex: 0 1 300px; }
.ars-search input {
  font-family: var(--ui);
  font-size: 14px; letter-spacing: 0.02em;
  color: var(--ink);
  background: var(--s2);
  border: 0;
  border-bottom: 1px solid var(--rule-strong);
  padding: 8px 11px;
  width: 100%;
  transition: background var(--fast) var(--ease), border-color var(--fast) var(--ease);
}
.ars-search input::placeholder { color: var(--ink-3); }
.ars-search input:hover { background: var(--s3); }
.ars-search input:focus { outline: none; background: var(--s3); border-bottom-color: var(--gold); }
/* An empty result must not read as a broken screen, so it is a sentence at body
 * weight rather than a blank area. */
.ars-empty {
  margin: 4px 0 0;
  font-size: 13px; line-height: 1.5;
  color: var(--ink-2);
}
/* Screen-reader-only. Used by the arsenal's search label and its result count —
 * the count is a live region, so it must be in the accessibility tree and merely
 * invisible, which `display: none` would not achieve. */
.vh-label {
  position: absolute;
  width: 1px; height: 1px;
  overflow: hidden; clip: rect(0 0 0 0);
  white-space: nowrap;
}

/* The composition panel's two homes. It is MOVED between them by
 * js/lobby-composition.js — see relocate() there for why it is not duplicated.
 * The menu slot spans the deployment grid so the two steppers sit under the
 * fields rather than beside them. */
.comp-slot { min-width: 0; }
#comp-slot-menu:empty { display: none; }
#comp-slot-menu { margin-top: 14px; }

/* ---------------------------------------------------------------------------
   Multiplayer lobby: the two badges in the title band.
   Every other selector js/net/lobby-ui.js writes lives in the lobby block above,
   beside the layout it belongs to — validate-ui-contract.mjs treats a JS-written
   class with no rule as a contract violation rather than a nicety, and a second
   home for half of them is how one of the two goes stale.
   --------------------------------------------------------------------------- */

#lobby-role {
  font-size: .46em; letter-spacing: .14em; vertical-align: middle;
  padding: .3em .7em; margin-left: .5em;
  border: 1px solid currentColor;
  font-family: var(--ui); font-weight: 600;
}
/* ---------------------------------------------------------------------------
   "RISHI INVITED YOU." — the line that keeps the share card's promise.

   Set by js/main.js from the `from=` parameter that api/invite.mjs forwards, and
   `hidden` at rest so a player who opened this modal from the menu sees the screen
   unchanged. It sits ABOVE the code field on purpose: it is the reason the field is
   already filled, and an explanation printed under the thing it explains is one the
   reader has already stopped needing.

   A FILLED SURFACE, NOT A SIDE STRIPE, AND NOT GOLD. Two house rules meet on this
   one rule and both were written down before it existed.

   `validate-ui-slop.mjs` forbids a coloured bar on the edge of a box in as many
   words — "emphasis comes from type weight, a filled surface, or column alignment"
   — and the first draft of this block was exactly the `border-left: 2px` it names.
   So the emphasis is a raised surface (`--s2`, the token for header bands and
   selected rows) plus undimmed `--ink` against the modal's secondary copy.

   And not `--gold`: the palette reserves it for the primary action and the current
   selection at its own declaration, and diluting the one colour on this screen that
   means "press this" would cost more than this sentence is worth.
   --------------------------------------------------------------------------- */
.join-invite {
  margin: 0 0 14px;
  padding: 9px 12px;
  background: var(--s2);
  font-size: 12.5px; letter-spacing: .03em; line-height: 1.5;
  color: var(--ink);
}

/* ---------------------------------------------------------------------------
   THE JOIN PROGRESS LINE.

   A spinner plus a sentence naming the stage. The ring is a bordered box with one
   transparent edge, rotated — no image, no SVG, and the animation is a `transform`,
   which is composited, so it keeps turning at the one moment it matters most: while
   the main thread is busy with the handshake it is reporting on.

   `prefers-reduced-motion` gets a pulse instead of a rotation rather than nothing at
   all: the point of the indicator is that SOMETHING is still moving, and a frozen
   ring beside "connecting" is the exact impression it exists to dispel.
   --------------------------------------------------------------------------- */
.join-status {
  display: flex; align-items: center; gap: 10px;
  margin: 10px 0 0; min-height: 1.4em;
  font-size: 12.5px; letter-spacing: .04em; color: var(--ink-2);
}
.join-spinner {
  width: 13px; height: 13px; flex: 0 0 13px; border-radius: 50%;
  border: 2px solid currentColor; border-top-color: transparent;
  opacity: .85;
  animation: joinSpin 720ms linear infinite;
}
@keyframes joinSpin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
  .join-spinner { animation: joinPulse 1.2s ease-in-out infinite; }
  @keyframes joinPulse { 0%, 100% { opacity: .9; } 50% { opacity: .3; } }
}

#lobby-role[data-role="host"]    { color: #d8ae5b; }
#lobby-role[data-role="client"]  { color: #7dd8b8; }
#lobby-role[data-role="offline"] { color: var(--ink-4); }

/* RECONNECTING — a FOURTH role state, and it has to look like none of the other
   three. A guest that loses the host in a lobby now spends up to fifteen seconds
   trying to get back in, and for those fifteen seconds the screen must not read as
   a working lobby: the badge is the loudest thing on it, so it is the thing that
   changes. Amber rather than the client's green, and it pulses, because a static
   badge over a frozen room is what "offline" already looks like.

   The pulse is on OPACITY only. A colour animation on a bordered inline element
   repaints the border every frame; opacity is composited, so this costs nothing on
   the machine that is currently having a bad enough time to need it. */
#lobby-role[data-role="reconnecting"] {
  color: #e0a341;
  animation: lobbyReconnect 1.1s ease-in-out infinite;
}
@keyframes lobbyReconnect { 0%, 100% { opacity: 1; } 50% { opacity: .45; } }

/* THE ROOM ITSELF, WHILE WE ARE OUTSIDE IT.

   Every control is already `disabled` from js/net/lobby-ui.js — that is the half
   that a keyboard cannot get around, and it is deliberately the half that does the
   real work. This is the half the eye reads: the interactive body of the lobby goes
   quiet so the badge and the sentence beside it are what the player looks at, while
   the roster and the lobby CODE stay fully legible, because those are the truth
   about the room being rejoined and blanking them is what made the reported
   screenshot useless.

   Two rails, named rather than a blanket rule on `.lobby-wrap`: `.lobby-brief` is the
   host's deployment settings and `#lobby-kit` is your own loadout, and both describe
   choices that cannot currently be made or sent. `.lobby-main` — the SQUAD roster — is
   left alone on purpose, and so is `.lobby-head`, which holds the lobby code, the
   invite link and the sentence explaining what is happening. Those are the parts a
   player needs MORE while they are outside the room, and erasing them is precisely
   what made the reported screenshot useless. */
#lobby[data-link="reconnecting"] .lobby-brief,
#lobby[data-link="reconnecting"] #lobby-kit {
  opacity: .42;
  filter: saturate(.5);
  transition: opacity 180ms var(--ease), filter 180ms var(--ease);
}
@media (prefers-reduced-motion: reduce) {
  #lobby-role[data-role="reconnecting"] { animation: none; }
  #lobby[data-link="reconnecting"] .lobby-brief,
  #lobby[data-link="reconnecting"] #lobby-kit { transition: none; }
}

/* The code is read out loud, so it is set at a size you can read across a room
   and tracked wide enough that O and 0 cannot be confused at a glance. */
.lobby-code-tag {
  font-family: var(--display); font-weight: 400;
  font-size: clamp(26px, 2.2vw, 34px); line-height: 1;
  letter-spacing: .22em;
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}

/* ============================== feedback dialog ==============================
   One dialog, opened from the menu (#btn-feedback) and from the pause screen
   (#btn-pause-feedback). See index.html's FEEDBACK block and js/feedback.js.

   NO ACCENT IS PAINTED HERE, AND THAT IS A HARD CONSTRAINT RATHER THAN A TASTE.
   `scripts/validate-ui-slop.mjs` caps `--gold` as a background/border at 14 rules
   and the sheet is at exactly 14. The one gold surface in this dialog is SEND
   REPORT, which wears `.btn.primary` — an existing rule, so the count does not
   move. Everything else here is surface ramp, rule hairlines and type colour.

   Layered against the scale rather than at a number: --z-modal is the pause
   screen this can open OVER, so it must be exactly one above it, and writing the
   relationship is what keeps that true when somebody moves the scale.
   --------------------------------------------------------------------------- */
.fb {
  position: fixed; inset: 0;
  z-index: calc(var(--z-modal) + 1);
  display: grid; place-items: center;
  padding: 4vh 4vw;
  overflow-y: auto;
  overscroll-behavior: contain;
}
.fb-backdrop {
  position: fixed; inset: 0;
  appearance: none;
  border: 0;
  background: rgba(4, 6, 8, 0.8);
  backdrop-filter: blur(8px);
  cursor: default;
}
.fb-card {
  position: relative;
  background: var(--s1);
  padding: clamp(22px, 3vw, 34px);
  width: min(520px, 100%);
  max-height: 92vh;
  overflow-y: auto;
  overscroll-behavior: contain;
  display: flex; flex-direction: column; gap: 16px;
  box-shadow: 0 40px 110px rgba(0, 0, 0, 0.8), inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.fb-head { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; }
.fb-head h2 {
  margin: 0;
  font-family: var(--display);
  font-size: 30px; letter-spacing: 0.07em; text-transform: uppercase;
}
.fb-x {
  appearance: none; background: none; border: 0;
  color: var(--ink-3);
  font-size: 26px; line-height: 1;
  padding: 2px 6px;
  cursor: pointer;
}
.fb-x:hover { color: var(--ink); }
.fb-x:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
.fb-sub { margin: 0; color: var(--ink-2); font-size: 13px; line-height: 1.6; max-width: 52ch; }

#fb-form { display: flex; flex-direction: column; gap: 16px; }

/* The three types. A row of radios rather than a select, because three exclusive
   options that all fit on one line are a choice you should be able to see. */
.fb-types { border: 0; padding: 0; margin: 0; min-width: 0; }
.fb-types legend {
  padding: 0 0 8px;
  font-family: var(--ui);
  font-size: 11.5px; font-weight: 600; letter-spacing: 0.05em;
  color: var(--ink-3); text-transform: uppercase;
}
.fb-type {
  display: inline-flex; align-items: center; gap: 8px;
  margin: 0 14px 0 0;
  font-size: 13.5px; color: var(--ink-2);
  cursor: pointer;
}
.fb-type input { accent-color: #a7b0b6; margin: 0; }
.fb-type:hover span { color: var(--ink); }
.fb-type:has(input:checked) span { color: var(--ink); }
.fb-type input:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }

/* The textarea inherits the shipped field treatment; only the shape is new. */
.fb-field textarea {
  font-family: var(--ui);
  font-size: 15px; line-height: 1.5; letter-spacing: 0.02em;
  color: var(--ink);
  background: var(--s2);
  border: 0;
  border-bottom: 1px solid var(--rule-strong);
  padding: 10px 12px;
  width: 100%; min-width: 0;
  resize: vertical;
  min-height: 120px;
  transition: background var(--fast) ease, border-color var(--fast) ease;
}
.fb-field textarea:hover { background: var(--s3); }
.fb-field textarea:focus { outline: none; background: var(--s3); border-bottom-color: var(--ink-3); }
.fb-field textarea:focus-visible { outline: 2px solid var(--blue); outline-offset: 1px; }

/* The live counter. Quiet until it matters, then it says so — a cap the player
   only discovers by hitting it is a cap that reads as the field being broken. */
.fb-count {
  font-family: var(--num); font-variant-numeric: tabular-nums;
  font-size: 11px; font-weight: 600; letter-spacing: 0.04em;
  color: var(--ink-4);
}
.fb-count[data-state="near"] { color: var(--ink-2); }
.fb-count[data-state="full"] { color: var(--red); }

/* In-flight, refused and failed. One element, three states, and it is never
   invisible while it has something to say. */
.fb-status { margin: 0; font-size: 13px; line-height: 1.55; min-height: 1.2em; color: var(--ink-2); }
.fb-status[data-state="error"] { color: var(--red); }
.fb-status[data-state="busy"]  { color: var(--ink-2); }
.fb-status:empty { min-height: 0; }

.fb-actions { display: flex; gap: 12px; flex-wrap: wrap; }
.fb-actions .btn { flex: 1 1 160px; }

.fb-context { margin: 0; font-size: 11.5px; line-height: 1.5; color: var(--ink-4); }

/* The success state. It replaces the form rather than sitting under it, so there
   is no reading required to know the report went. */
.fb-sent { display: flex; flex-direction: column; align-items: flex-start; gap: 12px; }
.fb-sent h3 {
  margin: 0;
  font-family: var(--display);
  font-size: 24px; letter-spacing: 0.07em; text-transform: uppercase;
  color: var(--ink);
}
.fb-tick { margin: 0; font-size: 34px; line-height: 1; color: var(--green); }
.fb-sent .btn { align-self: stretch; }

@media (max-width: 560px) {
  .fb { padding: 0; }
  .fb-card { width: 100%; max-height: 100vh; }
}

/* ==================== the medal stack ====================
 * *"im kidn of a fan of medals being top center and then having up to 3 at a time and it
 * just fades in and out and moves in and out smoothly rather than top right wdyt? can we
 * do that without overlappign shit?"*
 *
 * TOP CENTRE, IN THE LANE THE PLAYER'S EYE IS ALREADY IN during a kill — the reference is
 * Black Ops 2, which runs one column down the middle: medal icon, name beneath it, the
 * score popup under that, the reticle under that. Per medal, ours matches: ICON ABOVE
 * NAME. It used to be icon BESIDE name, which was the right shape for the right rail this
 * moved out of and the wrong one here.
 *
 * -----------------------------------------------------------------------------
 * WHY THE THREE MEDALS SIT SIDE BY SIDE RATHER THAN STACKED, WHICH IS ARITHMETIC
 * -----------------------------------------------------------------------------
 * Unlike the reference, this lane is NOT empty. `#score-header` owns the top of it and
 * `#banner` owns `top: 26%`, and the medals have to fit BETWEEN them. Measured in a live
 * match, `#score-header` bottom is a near-constant 63-64px while the banner tracks the
 * viewport, so the usable gap is:
 *
 *      600px tall   89px        1080px tall  218px
 *      800px tall  141px        1440px tall  306px
 *
 * One medal is its icon plus about 17px of caption. STACKED, three of them need
 * `3*icon + 63px`, which allows an icon of **8.7px at 600px tall** and 26px at 800px —
 * unreadable, and at the short end simply impossible. SIDE BY SIDE the height is one
 * medal regardless of how many are up, so it needs `icon + 17px` and clears every
 * supported size with room to spare.
 *
 * So the honest answer to *"can we do that without overlapping shit"* is: yes, three at a
 * time, top centre, icon over name — arranged across rather than down. A stacked column of
 * three would have had to either overlap the banner or shrink the art to nothing.
 *
 * `#banner` is only raised twice (FIGHT at the start, VICTORY/DEFEAT at the end) and the
 * second CAN coincide with a medal from the match-winning kill, so the clearance is real
 * rather than theoretical and it is asserted by `scripts/validate-medal-hud.mjs`.
 *
 * -----------------------------------------------------------------------------
 * THE OFFSET IS ARITHMETIC, NOT A GUESS — same pattern as #streak over #weapon-block
 * -----------------------------------------------------------------------------
 *   12px  #score-header's own top inset
 *   52px  its measured height (clamped type; 51-52px from 1280 to 3440 wide)
 *   12px  the gutter this HUD uses between stacked elements
 * If the score header gains a row or its type grows, this is the number that changes —
 * and the bounding-box check goes red first, which is the point of it being a check.
 *
 * THE BADGE `:has()` RULES ARE GONE, DELIBERATELY. They pushed this element down past
 * #cheat-badge and #preset-badge while it lived in the right rail. Both badges sit at
 * `top: 18px` with a ~22px line box, so they end by 40px — above this element's 76px top,
 * in a different corner. Re-derived rather than carried over: a rule that survives a move
 * without its reason is a rule nobody can evaluate later. The scoreboard/endboard rules
 * DO survive, because a full-screen board owns the whole screen. */
#medal-stack {
  position: absolute;
  top: calc(12px + 52px + 12px);
  left: 50%;
  transform: translateX(-50%);
  display: flex;
  flex-direction: row;
  align-items: flex-start;
  justify-content: center;
  gap: clamp(10px, 1.4vw, 22px);
  /* Never so wide it reaches the badge column or the frame edge. */
  max-width: min(58vw, 620px);
  pointer-events: none;
  z-index: calc(var(--z-fx) + 1);
}
/* A full-screen board owns the screen. */
#hud:has(> #scoreboard:not(.hidden)) #medal-stack,
#hud:has(> #endboard:not(.hidden)) #medal-stack { display: none; }

/* The FLIP wrapper. It owns ONLY the re-centring slide, so the match-clock fade on the
 * row inside it cannot cancel this transform on the next frame — both want `transform`,
 * and one of them is rewritten sixty times a second. `.sliding` is added for exactly one
 * transition and the transform is cleared with it. */
.medal-cell {
  flex: 0 0 auto;
  /* NO PERCENTAGE CAP. `max-width: 33%` resolved against a container that is itself
   * sized by its content, which is circular — it clipped KILLTASTROPHE to
   * "KILLTASTRO..." while 200px of lane sat unused either side. The container's own
   * max-width is the only cap that has a fixed thing to resolve against, and the
   * longest caption in the set is about 130px at this type size, so three of them plus
   * the gaps fit inside it with room. */
}
.medal-cell.sliding { transition: transform 240ms cubic-bezier(0.22, 0.61, 0.36, 1); }

.medal-row {
  --a: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 3px;
  opacity: var(--a);
  /* Rises INTO place and sinks back out on the same number, so the exit is the entrance
   * reversed rather than a second piece of choreography to keep in step. This is the
   * match-clock half of *"moves in and out smoothly"*; the sideways re-centring when a
   * neighbour arrives is the FLIP on the wrapper above. */
  transform: translateY(calc((1 - var(--a)) * -10px));
}
.medal-name {
  font-family: var(--ui);
  font-size: clamp(10px, 0.95vw, 13px);
  font-weight: 600;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  text-align: center;
  /* `--ink-1` DOES NOT EXIST — the ramp is --ink / --ink-2 / --ink-3 / --ink-4. This is the
     SECOND instance of that typo in this file; the first is documented at the score-plate
     rule above, which says of it: "the declaration was invalid and the colour silently fell
     through to the inherited value. It happened to look right, which is the dangerous kind
     of wrong." A fallback does not rescue it either — `var(--ink-1, #e8eaec)` pinned a
     hard-coded grey that no theme could reach, one shade off the real token. */
  color: var(--ink);
  text-shadow: var(--hud-shadow);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  max-width: 100%;
}
/* The two ladders are told apart by their TYPE colour, not by a second badge. A spree is
 * the rarer axis — you have not died in twenty kills — so it takes the gold this HUD
 * already reserves for the player's own achievement (#streak, .kf-row.mine). */
.medal-row.is-spree .medal-name { color: var(--gold); }
/* The top rungs used to get EXTRA letter-spacing, which was exactly backwards: the
 * highest rungs carry the longest words (KILLTASTROPHE, KILLPOCALYPSE, KILLIMANJARO), so
 * the emphasis was applied to the captions least able to afford the width. They are
 * already distinguished by their art and by being rare. */

.medal-art {
  /* vmin, so a SHORT window shrinks the badge as well as a narrow one — the lane's height
   * is what binds here, and a vw-only clamp would keep a 56px icon on a 600px-tall window
   * where the gap is 89px. */
  width: clamp(34px, 5.2vmin, 56px);
  height: clamp(34px, 5.2vmin, 56px);
  flex: 0 0 auto;
  display: block;
  background-repeat: no-repeat;
  background-position: center;
  background-size: contain;
  /* The art is a transparent cut-out; a drop shadow is what keeps it legible over a bright
   * sky, the same problem #score-header solves with a scrim. A scrim would box the medal
   * and lose the silhouette. */
  filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.85));
}
/* A CELL WITH NO PICTURE IS STILL A MEDAL. The bitmap may not have decoded yet — the art
 * is deliberately off the critical path — so the icon box collapses and the caption stands
 * alone. That is the state the HUD was in before the art existed, and it is complete. */
.medal-art.empty { height: 0; }

/* ============================== invite dialog ==============================
 * The dialog that opens when you create a lobby. It sits ON the lobby (`.modal`
 * already supplies the scrim and `var(--z-modal)`), because the room you just made
 * is worth seeing behind the link you are about to send.
 *
 * NO NEW ACCENT SURFACE. COPY LINK is `.btn.primary`, which already carries the one
 * gold fill the design budget allows; the state colours below are TEXT, which is
 * what a status line should be anyway. */
.invite-card { width: min(520px, 100%); gap: 14px; }
.invite-card .sub { margin: 0; max-width: 52ch; }

/* THE WAIT. A row rather than a background, so reduced motion can stop the spinner
 * without taking the sentence away with it. */
.invite-wait { display: flex; align-items: center; gap: 10px; }
.invite-spinner {
  width: 14px; height: 14px; flex: 0 0 auto;
  border-radius: 50%;
  border: 2px solid var(--rule-strong);
  border-top-color: var(--ink-2);
}
@media (prefers-reduced-motion: no-preference) {
  .invite-spinner { animation: mcSpin 900ms linear infinite; }
}
@keyframes mcSpin { to { transform: rotate(1turn); } }

/* THE THREE STATES, DRIVEN BY ONE ATTRIBUTE. `display: none` on the parts that do
 * not belong to the current state rather than a `hidden` attribute on each: an
 * author `display` declaration beats the user-agent `[hidden]` rule, which this
 * project has already been bitten by once (a listbox panel that stayed painted over
 * a live match while every closer had run correctly). One writer, one attribute. */
#invite[data-state="pending"] .invite-field,
#invite[data-state="pending"] .invite-copy-note,
#invite[data-state="pending"] #btn-invite-copy,
#invite[data-state="error"] .invite-field,
#invite[data-state="error"] .invite-copy-note,
#invite[data-state="error"] #btn-invite-copy,
#invite[data-state="ready"] .invite-wait { display: none; }

/* THE COPY STATUS. Green only when the clipboard promise actually resolved; the
 * failure branch is the same weight and colour as any other thing the player has to
 * act on, because it IS one — it names the button they now have to press. An empty
 * note collapses rather than leaving a gap the eye reads as a missing sentence. */
.invite-copy-note[data-copied="yes"] { color: var(--green); }
.invite-copy-note[data-copied="no"] { color: var(--ink-2); }
.invite-copy-note:empty { display: none; }
.invite-hint { border-top: 1px solid var(--rule); padding-top: 14px; }
.invite-field input { width: 100%; }

/* WHAT ESCAPE DOES IN FULL SCREEN — only written when the Keyboard Lock API is
 * actually there to make it true, so the paragraph collapses on Firefox and Safari
 * rather than promising a behaviour those browsers cannot perform. */
.fs-esc:empty { display: none; }

/* ==========================================================================
   THE SPAWN-IN LOADOUT OVERLAY
   ==========================================================================
   The user: *"when you spawn in for first time in those first 10 seconds you should
   overlay transaprent select class menu ... and it should be overlaid so u still see
   the stuff in background"*.

   THIS IS THE LOADOUT SCREEN, RESKINNED — not a second panel. js/main.js sets
   `data-overlay="spawn"` on `#classes` while the countdown owns it, so the picker, the
   preset filtering and the store are the same ones the menu uses. Everything below is
   opacity and geometry; nothing here changes what a control does.

   NO `backdrop-filter` ON THE FULL-SCREEN LAYER, DELIBERATELY. The post-processing fill
   budget has roughly a tenth of its headroom left, and a blur over the whole viewport is
   a full-screen read of the composited frame on every one of the ~600 frames a ten-second
   countdown lasts. A flat translucent surface composites for free by comparison. The
   sticky action bar keeps its own 3px blur because it is one 60px band, not the viewport.

   The gradient floor `#classes` carries has to be overridden as well as `.screen`'s
   background: an author declaration beats an author declaration by specificity, and a
   rule that only removed one of the two would leave the screen opaque while every state
   said it was transparent. */
#classes[data-overlay="spawn"] {
  background: linear-gradient(180deg, rgba(6, 8, 10, 0.55) 0%, rgba(6, 8, 10, 0.2) 45%, rgba(6, 8, 10, 0.72) 100%);
}

/* ==========================================================================
   ...AND IT HAS TO OUTRANK THE LOADING VEIL, BECAUSE THE COUNTDOWN IT BELONGS
   TO STARTS WHILE THE VEIL IS STILL UP.
   ==========================================================================
   Reported: *"u need to see a cursor in that time / anytime when u have menu or loadout
   classes open"*. Half of that was pointer lock (js/main.js `lockPointerForMatch`); this
   is the other half, and it is a HIT-TESTING fault, not a cursor one. Measured on the
   shipped page with the panel open during a load:

       #classes  z-index 20   pointer-events auto
       #loading  z-index 60   pointer-events auto   opacity 1, background rgb(6, 8, 10)

   Forty z-index levels of opaque plate over a live picker: the cursor was drawn and every
   press landed on the veil. `Game.init()` publishes `data-spawnin` before it returns, and
   on a guest the veil then stays up for the whole spawn rendezvous — so the panel spends
   most of its ten seconds underneath this.

   EXPRESSED AS A RELATIONSHIP, NEVER A NUMBER — `--z-fx` came to equal `--z-modal` by
   somebody writing 40. The panel is "one above the veil", so moving the veil moves it.

   AND SCOPED TO `data-loading`, so the promotion lasts exactly as long as its reason. Once
   the veil is down the panel goes back to `--z-screen` with every other screen, rather
   than sitting permanently above a layer scale it has no business in. */
#classes[data-overlay="spawn"][data-loading="1"] { z-index: calc(var(--z-loading) + 1); }

/* THE LOAD MUST STILL BE LEGIBLE UNDERNEATH IT. `#loading` docks its own readout to the
   BOTTOM (`align-content: end`), so reserving the bottom band is all it takes for the bar,
   the percentage and the stage caption to stay visible beside the panel rather than being
   traded away for it — *"a veil that lifts on a landmark is worse than no veil, because it
   promises the waiting is over and then stops answering"*, and a veil painted over is the
   same thing with an extra step.

   `.screen` is `position: fixed; inset: 0`, so raising `bottom` shrinks the overlay's box —
   which the sticky `.sheet-foot` then sticks to, keeping CONFIRM — DEPLOY inside the panel
   instead of over the loading bar. `data-loading` is written by js/main.js's
   `syncSpawnOverlayVeil()` and cleared by `hideLoading`, so it is true exactly while both
   are up.

   AND THE BAND IS MEASURED, NOT PICKED. `--spawn-veil-band` is written by
   `syncSpawnOverlayVeil()` from the strip's own rendered position, so a longer rendezvous
   message or a narrower viewport moves the band instead of landing the sheet on top of it.
   A flat 172px was tried first and left ONE PIXEL of margin at 1280x720 once the rendezvous
   line was present — *"shaving pixels each time is losing a race."* The literal below is
   only the fallback for the frame before the first measurement; it is not the number the
   layout relies on. */
#classes[data-overlay="spawn"][data-loading="1"] { bottom: var(--spawn-veil-band, 172px); }
/* The two rows that make the veil tall. Dropping them while the panel is up keeps the
   strip to label + percentage + bar + the decode count, which is what the reserved band
   above is sized for. They come back the moment the panel closes. */
#classes[data-overlay="spawn"][data-loading="1"] ~ #loading #load-steps,
#classes[data-overlay="spawn"][data-loading="1"] ~ #loading #load-tip { display: none; }
/* Narrower and inset, so the map reads down both sides rather than only through the
   panel. `min-height: auto` undoes `.sheet`'s `100%`, which otherwise stretches an
   opaque-ish plate over the whole viewport whatever its background says. */
#classes[data-overlay="spawn"] .sheet {
  min-height: auto;
  width: min(1180px, 100%);
  margin: clamp(10px, 2.5vh, 26px) auto;
  background: rgba(11, 14, 16, 0.82);
  outline: 1px solid var(--rule-strong);
  gap: clamp(14px, 2.2vh, 24px);
  padding-top: clamp(16px, 3vh, 34px);
  padding-bottom: clamp(18px, 3vh, 34px);
}
/* The countdown is the thing the player is actually watching, so the panel must not
   sit over it. #spawnin is centred in the HUD; this pushes the sheet's own head down
   far enough to leave the count visible above the panel on a short window too. */
#classes[data-overlay="spawn"] .sheet-head h2 { font-size: clamp(22px, 2.4vw, 32px); }
#classes[data-overlay="spawn"] .sheet-foot {
  background: linear-gradient(180deg, transparent, rgba(6, 8, 10, 0.9) 38%);
}
/* THE ONE THING THE OVERLAY ADDS RATHER THAN RESTYLES: the sentence that says this is
   optional and how to get back to it. Written in the markup by js/main.js so it cannot
   claim a key nothing binds — see `closeSpawnLoadout`, which toasts the same route. */
#classes[data-overlay="spawn"] .sheet-head::after {
  content: 'The countdown is still running behind this. Confirm when you are ready — or ignore it and it closes itself.';
  flex: 1 0 100%;
  order: 3;
  margin: 0;
  font-family: var(--ui);
  font-size: 12px;
  line-height: 1.5;
  color: var(--gold);
  letter-spacing: 0.02em;
}
