/* Front Line — 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. 
 *      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
 *
 * `--cta` is the contrast-safe field-cyan action plate. `--signal` / the legacy
 * `--gold` alias is selection, focus and brand edge. `--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: #0a0c0b;            /* olive-black page floor */
  --s1: #101412;            /* opaque field panel */
  --s2: #171c19;            /* raised: header bands, selected column */
  --s3: #202722;            /* hover */
  --s4: #29322d;            /* pressed / active */

  --ink:    #e6ebe8;
  --ink-display: #f2efe6;
  --ink-2:  #aab5b1;
  --ink-3:  #77847f;
  --ink-4:  #4d5a55;

  /* UI-only field signal. Never use this for teams or in the 3D combat space. */
  --signal:   #3dd6c6;
  --signal-d: #25a99d;
  --warn:     #f0a202;
  /* One primary action per screen: cyan plate with contrast-safe dark ink. */
  --cta:      #3dd6c6;
  --cta-ink:  #0a0e10;
  --gold:  var(--signal);
  --gold-d: var(--signal-d);
  --blue:  #6bc2ff;         /* friendly only */
  --red:   #ff5a4e;         /* hostile, damage, danger */
  --green: #8fd98b;         /* ready, confirmed */

  --rule:        rgba(230, 235, 232, 0.085);
  --rule-strong: rgba(230, 235, 232, 0.17);

  --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. Tracking stays below 0.12em so small labels remain words. */
  --display: 'Saira Condensed', 'Arial Narrow', system-ui, sans-serif;
  --ui: 'IBM Plex Sans', 'Segoe UI', system-ui, -apple-system, sans-serif;
  --mono: 'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
  --num: 'IBM Plex Mono', ui-monospace, monospace;

  /* --- 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 `.sheet-foot`. `--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. */
  --fl-stagger: 1;
  --fl-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(--signal);
  --accent-2: var(--blue);
  --danger: var(--red);
  --good: var(--green);
  --line: var(--rule);
  --font: var(--display);
}

@font-face {
  font-family: 'Saira Condensed';
  src: url('assets/fonts/saira-condensed-latin-600.woff2') format('woff2');
  font-weight: 600;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'Saira Condensed';
  src: url('assets/fonts/saira-condensed-latin-700.woff2') format('woff2');
  font-weight: 700;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'IBM Plex Sans';
  src: url('assets/fonts/ibm-plex-sans-latin-400-600.woff2') format('woff2');
  font-weight: 400 600;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'IBM Plex Mono';
  src: url('assets/fonts/ibm-plex-mono-latin-500.woff2') format('woff2');
  font-weight: 500;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'IBM Plex Mono';
  src: url('assets/fonts/ibm-plex-mono-latin-600.woff2') format('woff2');
  font-weight: 600;
  font-style: normal;
  font-display: swap;
}

* { box-sizing: border-box; }

/* Saira ships real 600/700 weights; never ask the browser to synthesize one. */
h1, h2, h3, h4 { font-weight: 600; }
.brand h1, .sheet-head h2, .modal-card h2, .panel-label,
#scoreboard h3, #endboard h2, #load-label, .dg-mark, #device-gate h1 { font-weight: 700; }

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;
  /* A transparent/cleared backbuffer must not turn into a pure-black world under the
     still-live HUD. This is a last visual safety net; the renderer's recovery owner
     paints the same blue-grey when even its direct world draw fails. */
  background: #263342;
}

/* A lost WebGL context owns the canvas and presents black regardless of its CSS
 * background, so the context-loss fallback has to be DOM. It remains below the HUD:
 * score, feed and minimap staying live is useful evidence that the match is alive.
 * During an ordinary PostFX retry the backdrop is transparent and the retained,
 * completely composited frame remains visible underneath the small status plate. */
#render-recovery {
  position: fixed; inset: 0;
  z-index: calc(var(--z-hud) - 1);
  display: grid;
  place-items: end center;
  padding: 0 18px 8vh;
  pointer-events: none;
  background: transparent;
}
#render-recovery[hidden] { display: none; }
#render-recovery[data-mode="context-lost"],
#render-recovery[data-mode="restoring"],
#render-recovery[data-mode="fallback-slate"] {
  place-items: center;
  padding: 18px;
  background:
    radial-gradient(circle at 50% 35%, rgba(92, 121, 144, 0.28), transparent 48%),
    #263342;
}
.render-recovery-card {
  display: grid;
  gap: 7px;
  width: min(430px, calc(100vw - 36px));
  padding: 13px 16px;
  color: var(--ink);
  background: rgba(16, 20, 18, 0.92);
  border-top: 2px solid var(--signal);
  font-family: var(--ui);
  font-size: 13px;
  line-height: 1.4;
  text-align: left;
  text-shadow: none;
}
.render-recovery-card strong {
  font-family: var(--display);
  font-size: 15px;
  letter-spacing: 0.08em;
}
.render-recovery-card span { color: var(--ink-2); }
#render-recovery-reload {
  justify-self: start;
  margin-top: 4px;
  padding: 8px 11px;
  border: 0;
  color: var(--cta-ink);
  background: var(--cta);
  font: 700 12px/1 var(--ui);
  letter-spacing: 0.06em;
  cursor: pointer;
  pointer-events: auto;
}
#render-recovery-reload[hidden] { display: none; }

/* ============================== 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;
  text-decoration: none;
  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(--cta);
  color: var(--cta-ink);
  border-color: transparent;
  font-weight: 800;
  font-size: 22px;
  padding: 18px 28px;
  letter-spacing: 0.08em;
}
.btn.primary:hover { background: #ffffff; }
.btn.primary:active { background: #c5ccd3; color: var(--cta-ink); }
.menu-main .btn.primary {
  /* Kept as the one filled slab, but not a billboard — size matches the row list. */
  font-size: clamp(16px, 1.55vw, 19px);
  padding: 12px 16px;
  letter-spacing: 0.09em;
}

.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, #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 equipment 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, which stood at 14 at the time.
   (The limit is 17 today and this rule is entry #2 in that file's census; the number is
   restated here rather than corrected in place, because the argument is about the bar not
   moving and quoting a stale one is how the argument stops being checkable.) 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.

   AND THE GOLD PRIMARY IN THAT SENTENCE IS NOW LITERAL ON THIS ROW. `#btn-classes-back`
   wears `.btn.primary` while the loadout sheet is the spawn overlay (`js/main.js`,
   `openLoadoutScreen`), so ARMED and CONFIRM — DEPLOY can appear in the same foot: amber
   outline against gold fill, which is the separation this comment already assumed. */
.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.08em;
  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: auto 1fr auto;
  padding: 0;
  background: var(--s0);
}
.access-banner {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  flex-wrap: wrap;
  padding: 10px clamp(22px, 5vw, 78px);
  background: color-mix(in srgb, var(--signal) 16%, var(--s1));
  border-bottom: 1px solid var(--line);
  font-size: 14px;
  letter-spacing: 0.02em;
  z-index: 5;
}
/* Author `display:flex` defeats UA [hidden] — same fault documented for .field. */
.access-banner[hidden] { display: none !important; }
.access-banner .btn { flex: 0 0 auto; }
.career-panel {
  padding: 14px clamp(22px, 5vw, 78px) 0;
  font-size: 12.5px;
  opacity: 0.92;
  letter-spacing: 0.03em;
  /* Sit tight under the top edge — was reading too low over the poster. */
  margin: 0;
}
.career-panel[hidden] { display: none !important; }
.career-panel .career-line { margin: 1px 0; }

/* Campaign ops / briefing / debrief */
.ops-sheet { max-width: 920px; }
.ops-board-body { display: grid; gap: 22px; margin: 18px 0 24px; max-height: min(62vh, 640px); overflow: auto; }
.ops-note { opacity: 0.85; font-size: 13px; margin: 0 0 8px; }
.ops-act-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 0 0 10px; }
.ops-act h3 { font-size: 13px; letter-spacing: 0.08em; text-transform: uppercase; opacity: 0.72; margin: 0; }
.ops-film-btn {
  border: 0; background: transparent; color: var(--gold); padding: 4px 0;
  font: 700 10px/1 var(--ui); letter-spacing: 0.08em; cursor: pointer;
}
.ops-film-btn:hover, .ops-film-btn:focus-visible { color: var(--ink); }
.ops-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
.ops-card {
  width: 100%; text-align: left; display: grid;
  grid-template-columns: 1fr auto auto auto; gap: 10px 14px; align-items: center;
  padding: 12px 14px; border: 1px solid var(--line); background: rgb(8 12 16 / 0.55);
}
.ops-card.is-complete { border-color: rgb(120 180 140 / 0.45); }
.ops-card.is-locked { opacity: 0.45; }
.ops-card-title { font-weight: 600; letter-spacing: 0.04em; }
.ops-card-map { font-size: 12px; opacity: 0.65; text-transform: uppercase; }
.ops-card-progress { font-size: 12px; opacity: 0.8; }
.op-stars { letter-spacing: 0.08em; color: rgb(255 210 90 / 0.35); }
.op-star.lit { color: rgb(255 210 90 / 0.95); }

.campaign-overlay {
  position: fixed; inset: 0; z-index: 40; display: grid; place-items: center;
  padding: clamp(8px, 2dvh, 20px);
  background: rgb(4 8 12 / 0.82); backdrop-filter: blur(6px);
}
.campaign-overlay.hidden { display: none; }
.campaign-cinematic {
  position: fixed; inset: 0; z-index: 48; display: grid; place-items: center;
  padding: clamp(10px, 2vw, 24px); background: #030506;
}
.campaign-cinematic.hidden { display: none; }
.campaign-cinematic-frame {
  position: relative; width: min(1280px, 100%); max-height: 100%; aspect-ratio: 16 / 9;
  overflow: hidden; background: #030506; border: 1px solid rgb(255 255 255 / 0.15);
  box-shadow: 0 28px 100px rgb(0 0 0 / 0.7);
}
.campaign-cinematic video { width: 100%; height: 100%; display: block; object-fit: contain; background: #030506; }
.campaign-cinematic-shade {
  position: absolute; inset: 0; pointer-events: none;
  background: linear-gradient(180deg, rgb(0 0 0 / 0.18) 38%, rgb(0 0 0 / 0.9) 100%);
}
.campaign-cinematic-copy {
  position: absolute; left: clamp(18px, 4vw, 58px); right: clamp(18px, 18vw, 220px);
  bottom: clamp(18px, 5vw, 60px); text-shadow: 0 2px 16px #000;
}
.campaign-cinematic-kicker {
  margin: 0 0 6px; color: var(--gold); font: 700 clamp(10px, 1.2vw, 13px)/1.2 var(--ui);
  letter-spacing: 0.08em; text-transform: uppercase;
}
.campaign-cinematic-copy h2 {
  margin: 0; color: #fff; font-size: clamp(25px, 4vw, 52px); letter-spacing: 0.04em;
  text-transform: uppercase;
}
.campaign-cinematic-story {
  max-width: 760px; margin: 8px 0 0; color: rgb(255 255 255 / 0.9);
  font-size: clamp(12px, 1.5vw, 17px); line-height: 1.45;
}
.campaign-cinematic-skip {
  position: absolute; top: clamp(14px, 2vw, 24px); right: clamp(14px, 2vw, 24px);
  padding: 9px 14px; border: 1px solid rgb(255 255 255 / 0.42); background: rgb(3 5 6 / 0.62);
  color: #fff; font: 700 11px/1 var(--ui); letter-spacing: 0.08em; cursor: pointer;
}
.campaign-cinematic-skip:hover, .campaign-cinematic-skip:focus-visible { border-color: var(--gold); color: var(--gold); }
@media (max-height: 520px) {
  .campaign-cinematic-copy { bottom: 12px; }
  .campaign-cinematic-story { max-width: 82%; font-size: 11px; }
}
.campaign-plate {
  width: min(720px, 92vw); padding: clamp(18px, 3vw, 28px);
  border: 1px solid var(--line); background: rgb(10 14 18 / 0.92);
  box-shadow: 0 24px 80px rgb(0 0 0 / 0.45);
}
#briefing .campaign-plate {
  box-sizing: border-box;
  width: min(720px, 100%);
  max-height: calc(100vh - clamp(16px, 4vh, 40px));
  max-height: calc(100dvh - clamp(16px, 4dvh, 40px));
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  align-content: start;
  gap: clamp(5px, 1dvh, 10px);
  overflow: hidden;
}
#briefing .campaign-plate > * { min-width: 0; }
#briefing .campaign-act,
#briefing .campaign-blurb,
#briefing .briefing-media,
#briefing .briefing-missions,
#briefing .campaign-difficulty,
#briefing .campaign-difficulty-note,
#briefing .campaign-actions { margin: 0; }
#briefing .campaign-plate h2 {
  margin: 0;
  font-size: clamp(24px, 4dvh, 36px);
  line-height: 1;
}
.campaign-act { font-size: 12px; letter-spacing: 0.08em; text-transform: uppercase; opacity: 0.7; margin: 0 0 8px; }
.campaign-blurb, .campaign-sub { opacity: 0.88; line-height: 1.45; margin: 8px 0 14px; }
.briefing-media { margin: 0 0 12px; }
.briefing-media-wrap {
  aspect-ratio: 16 / 9; overflow: hidden; border: 1px solid var(--line);
  background: #0a0e12;
}
#briefing .briefing-media-wrap {
  width: 100%;
  height: clamp(86px, 24dvh, 220px);
  aspect-ratio: auto;
}
.briefing-media-wrap img, .briefing-media-wrap video { width: 100%; height: 100%; object-fit: cover; display: block; }
.briefing-missions { margin: 0 0 16px; padding-left: 1.1em; line-height: 1.5; }
.briefing-missions li.done { color: rgb(140 210 160); }
.campaign-difficulty { display: grid; gap: 6px; margin: 0 0 4px; color: var(--muted, #aeb3ba); font-size: 11px; letter-spacing: .12em; }
.campaign-difficulty select { min-height: 40px; padding: 0 12px; color: #f4f1e8; background: #111820; border: 1px solid rgb(220 173 75 / 55%); font: inherit; letter-spacing: .08em; }
.campaign-difficulty-note { min-height: 1.4em; margin: 0 0 16px; color: #c8c9c5; font-size: 12px; }
.campaign-actions { display: flex; gap: 10px; flex-wrap: wrap; }
#briefing .campaign-actions { flex-wrap: nowrap; }
#briefing .campaign-actions .btn { min-height: 40px; padding-block: 8px; }

@media (max-height: 620px), (max-width: 520px) {
  #briefing .campaign-plate { padding: clamp(10px, 2.2dvh, 16px); gap: 5px; }
  #briefing .campaign-act { font-size: 10px; line-height: 1.1; }
  #briefing .campaign-plate h2 { font-size: clamp(21px, 6dvh, 28px); }
  #briefing .briefing-media-wrap { height: clamp(58px, 17dvh, 104px); }
  #briefing .campaign-blurb {
    display: -webkit-box;
    overflow: hidden;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 2;
    font-size: 11px;
    line-height: 1.25;
  }
  #briefing .briefing-missions {
    font-size: 11px;
    line-height: 1.25;
  }
  #briefing .campaign-difficulty { gap: 3px; font-size: 9px; }
  #briefing .campaign-difficulty select { min-height: 34px; padding-inline: 9px; }
  #briefing .campaign-difficulty-note { min-height: 0; font-size: 10px; line-height: 1.2; }
  #briefing .campaign-actions { gap: 7px; }
  #briefing .campaign-actions .btn { flex: 1 1 0; min-height: 36px; padding: 6px 10px; }
}
.debrief-xp { font-size: 18px; letter-spacing: 0.06em; margin: 6px 0 12px; color: rgb(140 210 160); }
.intro-preview-plate { width: min(960px, 94vw); }
.intro-preview-plate .briefing-media-wrap { max-height: min(56vh, 540px); }

/* Front Line grade on Higgsfield clips — teal push + vignette */
.fl-video-grade { position: relative; }
.fl-video-grade::after {
  content: ''; position: absolute; inset: 0; pointer-events: none;
  background:
    radial-gradient(ellipse at center, transparent 42%, rgb(0 0 0 / 0.45) 100%),
    linear-gradient(180deg, rgb(20 80 90 / 0.12), rgb(180 90 40 / 0.08));
  mix-blend-mode: soft-light;
}
#menu-intro-video,
.menu-intro-video {
  position: absolute; inset: 0; z-index: 0; opacity: 0.28; pointer-events: none;
}
#menu-intro-video video,
.menu-intro-video video { width: 100%; height: 100%; object-fit: cover; }
.join-lobby-row.is-locked { opacity: 0.45; cursor: not-allowed; }
.menu-grid {
  display: grid;
  grid-template-columns: minmax(320px, 0.95fr) minmax(300px, 0.65fr);
  grid-template-areas: 'brand  setup' 'nav  setup';
  align-content: start;
  align-items: start;
  gap: clamp(14px, 2.2vh, 28px) clamp(40px, 7vw, 130px);
  width: 100%;
  margin: 0;
  padding: clamp(22px, 3.2vh, 40px) 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; }
/* CoD-style wordmark — one line, type only. */
.brand-mark {
  margin: 0;
  display: flex;
  flex-direction: row;
  flex-wrap: nowrap;
  align-items: baseline;
  gap: 0.18em;
  font-family: var(--display);
  font-weight: 800;
  line-height: 0.86;
  text-transform: uppercase;
  filter: drop-shadow(0 2px 14px rgba(0, 0, 0, 0.65));
  transform: skewX(-5deg);
  transform-origin: left center;
  white-space: nowrap;
  width: max-content;
  max-width: 100%;
}
.brand .brand-mark .bm-front,
.brand .brand-mark .bm-line {
  display: inline-block !important; /* beat .brand h1 span { display:block } */
  position: relative;
  letter-spacing: -0.02em;
  font-size: clamp(68px, 8.6vw, 148px);
  color: var(--ink);
  line-height: 0.86;
}
.brand-mark .bm-line {
  text-shadow:
    0.045em 0 0 color-mix(in srgb, var(--signal) 60%, transparent),
    -0.03em 0 0 color-mix(in srgb, var(--ink) 20%, transparent);
}
.brand-mark .bm-line::after { content: none; }
/* 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: 8px 0 0;
  font-family: var(--ui);
  font-size: 12px; letter-spacing: 0.08em;
  /* `--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: 8px;
}
.brand .menu-elsewhere a {
  color: var(--ink-3);
  font-family: var(--ui);
  font-size: 11.5px; font-weight: 600; letter-spacing: 0.08em;
  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;
  margin-top: 0;
}
.menu-main .btn.primary {
  width: auto;
  min-width: 11.5em;
  margin-bottom: 8px;
}
.menu-main .btn:not(.primary) {
  width: auto;
  min-width: 11.5em;
  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);
  /* Match Campaign's horizontal pad so PRACTICE / QUICK PLAY line up under the C. */
  padding: 10px 16px;
  font-size: 16px;
  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) {
  margin-left: 0;
  transition: background var(--fast) ease, color var(--fast) ease;
}
.menu-main .btn:not(.primary):active { transform: none; background: var(--s3); }

/* Console-style action hierarchy: the verb stays dominant at the left grid
 * line, while one restrained operational descriptor sits under it, right
 * justified, so Campaign / Practice / Quick Play share one right edge.
 * The whole row remains one native button. */
.menu-main .btn.menu-action {
  display: grid;
  grid-template-columns: 18px minmax(0, 1fr);
  grid-template-areas:
    "caret main"
    "caret meta";
  align-items: baseline;
  column-gap: 8px;
  row-gap: 2px;
  width: min(100%, 390px);
}
.menu-action-main { grid-area: main; white-space: nowrap; justify-self: start; }
.menu-action-meta {
  grid-area: meta;
  font-family: var(--ui);
  font-size: 11px;
  font-weight: 650;
  line-height: 1.2;
  letter-spacing: 0.08em;
  color: var(--ink-3);
  text-align: right;
  justify-self: stretch;
  white-space: nowrap;
  text-shadow: 0 1px 2px rgba(6, 8, 10, 0.92), 0 2px 10px rgba(6, 8, 10, 0.62);
}
.menu-main .btn.menu-action.primary .menu-action-meta {
  color: rgba(12, 15, 18, 0.68);
  text-shadow: none;
}
.menu-main .btn.menu-action:not(.primary):hover .menu-action-meta,
.menu-main .btn.menu-action:not(.primary):focus-visible .menu-action-meta { color: var(--ink-2); }

/* 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.08em;
  /* 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);
}
/* Practice-only — ui-boot sets [hidden] when #menu is not data-practice="1". */
#menu-summary[hidden] { display: none !important; margin: 0; }
#menu-summary b { color: var(--ink); font-weight: 600; }

/* --- practice setup rail: only when PRACTICE is armed. */
.menu-setup {
  grid-area: setup;
  background: rgba(11, 14, 16, 0.82);
  padding: clamp(14px, 1.8vw, 20px) clamp(14px, 1.8vw, 22px) clamp(14px, 1.8vw, 20px);
  display: flex; flex-direction: column;
  min-width: 0;
  backdrop-filter: blur(3px);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.055);
  /* Fit practice controls + Deploy without scrolling on typical desktop heights. */
  /* 78dvh left a reproducible 57px inner scroll at 1280x720: the Deploy
   * action was present but clipped below the fold. The panel starts around
   * 10dvh on that layout, so 86dvh fills the honest remaining column while
   * preserving a bottom margin and still yielding to the touch override. */
  max-height: min(86dvh, 920px);
  overflow-y: auto;
  overscroll-behavior: contain;
}
.menu-setup[hidden] { display: none !important; }
#menu:not([data-practice="1"]) .menu-grid {
  grid-template-columns: minmax(320px, 28rem);
  grid-template-areas: 'brand' 'nav';
  justify-content: start;
}
.menu-setup-lede {
  margin: 0 0 10px;
  font-size: 12.5px;
  line-height: 1.35;
  opacity: 0.78;
}
#btn-practice-deploy {
  margin-top: 12px;
  width: 100%;
  flex: 0 0 auto;
}
#btn-solo.is-armed {
  border-color: rgb(255 210 90 / 0.55);
  box-shadow: inset 0 0 0 1px rgb(255 210 90 / 0.25);
}
.menu-setup {
  scrollbar-width: thin;
  scrollbar-color: rgba(255, 255, 255, 0.16) transparent;
}
.menu-setup::-webkit-scrollbar { width: 9px; }
.menu-setup::-webkit-scrollbar-track { background: transparent; }
.menu-setup::-webkit-scrollbar-thumb {
  background: rgba(255, 255, 255, 0.14);
  border-radius: 99px;
  border: 3px solid transparent;
  background-clip: content-box;
}
.menu-setup:hover::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.24); background-clip: content-box; }
/* TWO COLUMNS, and the note that used to be here is now wrong, so it is gone
 * rather than reworded: *"two columns of 150px halved the room for 'Team
 * Deathmatch'"* was true when this rule ran at every width. It does not run at
 * every width any more — the sidebar is ~480px at the 1320px container, so a
 * column is ~215px and "Team Deathmatch" fits with room to spare. What actually
 * broke the old layout was that the 2x2 rule lived ONLY in the <=1040px
 * breakpoint, so the WIDE screen — the one with the most room — was the one
 * getting a single 10-row column.
 *
 * CALL SIGN spans, because it is a free-text field whose value is a name and
 * halving it to 215px is the one control here that genuinely suffers. */
.menu-setup-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 10px clamp(10px, 1.2vw, 16px);
}
.menu-setup-grid > .field-wide { grid-column: 1 / -1; }
/* Below ~560px of panel there is no honest second column left. Container query
 * rather than a viewport one: what decides this is how wide the PANEL is, and
 * the panel is a sidebar at some widths and full-bleed at others. */
.menu-setup { container-type: inline-size; container-name: setup; }
@container setup (max-width: 380px) {
  .menu-setup-grid { grid-template-columns: minmax(0, 1fr); }
}
.field-note { margin: 12px 0 0; font-size: 11.5px; line-height: 1.55; color: var(--ink-4); }
.field-note:empty { display: none; }

.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 
 *
 * 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 `--fl-dur` / `--fl-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-mark .bm-front { animation: flRise calc(620ms * var(--fl-dur)) var(--ease) calc(40ms  * var(--fl-stagger)) 1; }
  .brand-mark .bm-line  { animation: flRise calc(620ms * var(--fl-dur)) var(--ease) calc(105ms * var(--fl-stagger)) 1; }
  .tagline         { animation: flFade calc(520ms * var(--fl-dur)) var(--ease) calc(210ms * var(--fl-stagger)) 1; }
  #menu-summary    { animation: flFade calc(520ms * var(--fl-dur)) var(--ease) calc(300ms * var(--fl-stagger)) 1; }
  .menu-main .btn.primary { animation: flSlab calc(560ms * var(--fl-dur)) var(--ease) calc(255ms * var(--fl-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: flRow calc(480ms * var(--fl-dur)) var(--ease) calc(345ms * var(--fl-stagger)) 1; }
  .menu-main .btn:not(.primary):nth-of-type(3) { animation: flRow calc(480ms * var(--fl-dur)) var(--ease) calc(390ms * var(--fl-stagger)) 1; }
  .menu-main .btn:not(.primary):nth-of-type(4) { animation: flRow calc(480ms * var(--fl-dur)) var(--ease) calc(435ms * var(--fl-stagger)) 1; }
  .menu-main .btn:not(.primary):nth-of-type(5) { animation: flRow calc(480ms * var(--fl-dur)) var(--ease) calc(480ms * var(--fl-stagger)) 1; }
  .menu-setup      { animation: flPanel calc(640ms * var(--fl-dur)) var(--ease) calc(300ms * var(--fl-stagger)) 1; }


  /* --- the idle layer. Amplitudes are deliberately at the edge of perceptible. */
  /* The wordmark breathes: 0.4% of scale over nine seconds. */
  /* `flTitleJitter` 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: flBreath 9s ease-in-out 1.4s infinite,
               flTitleJitter 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: flTitleSlam 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 `flRise` 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: flRise calc(620ms * var(--fl-dur)) var(--ease) calc(105ms * var(--fl-stagger)) 1,
               flSweep 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: flGrain 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: flSheet 420ms var(--ease) 1; }
  .modal-card { animation: flModal 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` / `flModal`), 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: flCut 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: flCutSweep 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 { --fl-stagger: 0.28; --fl-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. */
  /* THE FIVE `#class-cards` COLUMN STAGGERS ARE GONE WITH THE CARDS. They brought the
   * premade-class comparison table in one column at a time; there are no premade classes.
   * Nothing replaces them: the arsenal's rows are deliberately unstaggered for the same
   * reason `.lobby-row` is, because that band re-renders on every filter keystroke. */
  /* 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: flFade 420ms var(--ease) 300ms 1; }
  .gs-optic  { animation: flFade 420ms var(--ease) 360ms 1; }

  /* Options: the three groups, left to right, after the title. */
  .opt-grid > .opt-group:nth-child(1) { animation: flCol 440ms var(--ease) 100ms 1; }
  .opt-grid > .opt-group:nth-child(2) { animation: flCol 440ms var(--ease) 150ms 1; }
  .opt-grid > .opt-group:nth-child(3) { animation: flCol 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: flCol 460ms var(--ease) 90ms  1; }
  .lobby-main          { animation: flCol 460ms var(--ease) 145ms 1; }
  .lobby-kit           { animation: flCol 460ms var(--ease) 200ms 1; }
  .lobby-map-name      { animation: flRise 520ms var(--ease) 150ms 1; }
  .comp-team[data-side=friendly] { animation: flFade 400ms var(--ease) 240ms 1; }
  .comp-team[data-side=hostile]  { animation: flFade 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: flFit 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 flRise { from { opacity: 0; transform: translateY(0.22em); } }
@keyframes flFade { from { opacity: 0; transform: translateY(6px); } }
@keyframes flRow  { from { opacity: 0; transform: translateX(-14px); } }
@keyframes flSlab { from { opacity: 0; transform: translateY(10px) scaleX(0.985); } }
@keyframes flPanel { from { opacity: 0; transform: translateY(14px); } }
@keyframes flSheet { from { opacity: 0; transform: translateY(10px); } }
@keyframes flModal { from { opacity: 0; transform: translateY(8px) scale(0.985); } }
@keyframes flCol { from { opacity: 0; transform: translateY(12px); } }
@keyframes flFit {
  /* 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 flBreath { 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 flCut { from { opacity: 0.94; } }
@keyframes flCutSweep {
  /* 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 flSweep {
  /* 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 flGrain {
  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.
 *
 * THE FIELD THIS WAS WRITTEN FOR IS GONE. js/net/lobby-ui.js used to hide
 * `#lobby-loadout-field` — the premade-class control — when a weapon preset was in force,
 * and both the control and the rule that hid it went with the classes. THE DECLARATION
 * STAYS, deliberately: it was already written for `.field` and `.opt` generally rather
 * than for the one id, so it is the thing that stops the NEXT `hidden` on either
 * inheriting the identical bug. Deleting it with its first caller would leave the next
 * author to rediscover the `.gsel-list` fault the hard way. */
.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;
  /* A <select> SIZES ITSELF FROM ITS WIDEST OPTION, so `width: 100%` alone does not
     constrain it -- the RADAR control ("Always on -- enemies always shown") pushed past
     the lobby rail and overlapped the scrollbar while every other control sat inside it.
     `min-width: 0` lets the box shrink; these three make the TEXT give way rather than the
     box grow, so all controls in a column stay the same width and the long label clips to
     an ellipsis instead of overflowing. */
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
  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.08em;
  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: flSelIn 130ms var(--ease) 1; }
}
@keyframes flSelIn { 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.08em; 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(4, minmax(0, 1fr));
  align-items: start;
  gap: clamp(10px, 1.2vw, 18px) clamp(12px, 1.4vw, 22px);
  width: min(1200px, 100%);
}
.opt-group { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
/* Options — fit one viewport; Controller was orphaning under a 3-col grid. */
#options .sheet {
  min-height: 100%;
  gap: clamp(10px, 1.6vh, 18px);
  padding-top: clamp(16px, 2.5vh, 28px);
  padding-bottom: clamp(12px, 2vh, 22px);
}
#options .sheet-body {
  overflow: visible;
  padding-bottom: 4px;
}
#options .sheet-head .sub { display: none; }
#options .opt-group .panel-label { margin: 0 0 2px; font-size: 11px; letter-spacing: 0.08em; }
#options .opt, #options .field { gap: 3px; }
#options .opt > span, #options .field > span { font-size: 10px; letter-spacing: 0.11em; }
#options .gs-note { display: none; }
#options #opt-pad-status { display: block; font-size: 10.5px; line-height: 1.3; margin: 0; opacity: 0.75; }
#options .opt input[type=range] { margin: 1px 0; }
#options .sheet-foot { padding-top: 8px; padding-bottom: 8px; }


/* EVERY CONTROL IN A COLUMN IS THE SAME WIDTH, AND NONE OF THEM MAY WIDEN IT.
 *
 * `.opt-grid` already uses `minmax(0, 1fr)` tracks and `.field`/`.opt` already carry
 * `min-width: 0`, and the RADAR and LAYOUT selects still escaped their column — because a
 * <select> reports the width of its widest OPTION as its min-content size, and that
 * propagates up through every intermediate box that has not also been told it may shrink.
 * One missing `min-width: 0` anywhere in the chain is enough. Pinning the control itself to
 * the column width closes it wherever the chain is, and makes "all the same width" a
 * property of the layout rather than of how long somebody's option text happens to be. */
.opt-group > .opt, .opt-group > .field, .menu-setup-grid > .field { min-width: 0; max-width: 100%; }
.opt-group select, .opt-group input, .menu-setup-grid select, .menu-setup-grid input {
  width: 100%; max-width: 100%; min-width: 0; box-sizing: border-box;
}
@media (max-width: 1100px) { .opt-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 640px) { .opt-grid { grid-template-columns: minmax(0, 1fr); } }

/* ============================== loadout ==============================
 * THE FIVE-COLUMN COMPARISON TABLE IS DELETED.
 *
 * `#class-cards`, `.card` with its `h4` / `p` / `sel` / `hover` / `focus-visible` states,
 * `.card-role`, `.card-guns`, `.card-bars` and the `.cb` / `.cb-l` / `.cb-t` stat-bar
 * vocabulary all styled ONE thing: the premade-class band. The user asked for the premade
 * classes to go altogether, js/main.js's `buildClassCards()` went with them, and a
 * stylesheet that keeps the rules for markup nothing produces is a layer reading as
 * present to the next reader — `validate-ui-contract`'s `unproduced` check exists for
 * exactly that and would have gone red on all nine class names.
 *
 * The three responsive branches that reflowed the table from five columns to three, two
 * and one went with it, from the 1180px, 860px and 620px blocks at the foot of this file.
 *
 * AND THE ZERO-LENGTH-BAR DEFECT RECORDED HERE GOES BECAUSE ITS SUBJECT DOES.
 * js/loadouts.js normalises each stat across the whole arsenal, so the worst weapon on a
 * stat landed at exactly 0 and MARKSMAN showed four completely empty rows, which a player
 * reads as missing data rather than as "lowest". `weaponBars` / `BAR_ORDER` are still
 * exported and still read by the arsenal's own weapon rows, so the normalisation is still
 * worth fixing and is still visible where it renders. 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 
 * 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(12px, 2vh, 22px);
  padding-top: clamp(10px, 1.6vh, 18px);
  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.08em;
  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;
  padding: 6px 10px;
  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: flFit 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.08em;
  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 TABS.
 * Each tab is one gun type. 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. */
.ars-cats {
  display: flex; flex-wrap: wrap; gap: 6px;
  margin: 8px 0 10px;
}
.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; }
.ars-item[data-career-locked="1"] {
  opacity: 0.82;
  cursor: not-allowed;
  position: relative;
}
.ars-item[data-career-locked="1"]:hover { background: transparent; }
.ars-item[data-career-locked="1"] .ars-name { color: var(--ink-3); }
.gs-sight[data-career-locked="1"] {
  opacity: 0.5;
  cursor: not-allowed;
}
.ars-lock {
  display: inline-block;
  margin-left: 0.45em;
  padding: 0.05em 0.4em;
  font-size: 0.72em;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink-2);
  border: 1px solid rgba(255, 255, 255, 0.18);
  border-radius: 2px;
  vertical-align: 0.1em;
}
.ars-item.on .ars-lock { color: rgba(20, 16, 10, 0.7); border-color: rgba(20, 16, 10, 0.28); }
#ars-unlock-banner {
  margin: 0 0 10px;
  padding: 8px 10px;
  font-size: 12px;
  line-height: 1.4;
  color: var(--ink-2);
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid rgba(255, 255, 255, 0.08);
}
#ars-unlock-banner[hidden] { display: none !important; }
#ars-unlock-banner b { color: var(--ink); font-weight: 600; }

/* ==================== 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. */
.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` IS DELETED WITH THE NUMBER IT STYLED. Each button used to carry its own
 * ADS cost in a tabular-figures span on the right — *"the optic should just say iron
 * sights and red dot sights not the bead and 300ms or all that other shit it just takes
 * up more room than needed and overflows"*. The cost survives in `#gs-note` under the
 * pair, which is where a full-width sentence has room for it. Removed rather than left
 * behind: `validate-ui-contract`'s `unproduced` check fails a rule nothing can match. */

/* --- equipment chip row (lethals). */
.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;
}
.gs-camo-btn.just-fitted { animation: flFit 420ms var(--ease) 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.08em;
  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.08em;
  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.08em;
  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.08em; }
#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.08em;
  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-equip { margin-top: 2px; }
.lobby-equip .panel-label { margin-bottom: 8px; }

/* 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 0 10px;
  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(61, 214, 198, 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.08em;
  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.08em;
  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],
#composition[data-mode=gun-game] .comp-team[data-side=hostile] { display: none; }
#composition[data-mode=ffa] .comp-team[data-side=friendly] .comp-slots,
#composition[data-mode=gun-game] .comp-team[data-side=friendly] .comp-slots { display: none; }
#composition[data-mode=ffa] .comp-team[data-side=friendly] .comp-name,
#composition[data-mode=gun-game] .comp-team[data-side=friendly] .comp-name { color: var(--ink-2); }
/* One bot pool, not a renamed team column: drop the team plate framing. */
#composition[data-mode=ffa] .comp-team[data-side=friendly],
#composition[data-mode=gun-game] .comp-team[data-side=friendly] {
  border-color: rgba(232, 234, 236, 0.12);
  background: rgba(8, 10, 12, 0.35);
}
#composition[data-mode=ffa] .comp-team[data-side=friendly] .comp-label,
#composition[data-mode=gun-game] .comp-team[data-side=friendly] .comp-label {
  letter-spacing: 0.08em;
  color: var(--ink-3);
}

/* ==================== 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); }

/* ====================== the match-start countdown ======================
 * ONE INSTANT, FOUR SCREENS. Everything visual here is a pure function of
 * `data-state` and the digit js/net/lobby-ui.js writes, and that digit is derived
 * from the shared host-clock instant on every tick — so the "cinematic" is
 * synchronised for the same reason the number is, rather than by a second
 * mechanism that would have to be kept in step with it.
 *
 * THE LAYER IS EXPRESSED AS A RELATIONSHIP, NOT A NUMBER. It must sit above the
 * sheet's own sticky action bar and below a modal, and writing that as a literal is
 * exactly how `--z-fx` came to equal `--z-modal` and paint the damage vignette over
 * the live endboard. */
.lobby-countdown {
  position: absolute; inset: 0;
  display: grid; place-items: center;
  z-index: calc(var(--z-screen-veil) + 1);
  /* NOT `backdrop-filter` and NOT a `background-image`.
   * js/overlay-flash-limiter.js discovers viewport-covering elements and treats any
   * candidate carrying a background-image as luminance 1 — deliberately, because a
   * gradient is unparseable — and then ramps its opacity at the guard's own step
   * limit. A boot veil that copied a faint gold radial sat between 0.53 and 0.94
   * opacity for its first two seconds because of exactly that. A flat scrim off
   * `--s0` has a relative luminance around 0.002 and leaves the guard's arithmetic
   * alone. */
  background: rgba(4, 6, 8, 0.72);
  pointer-events: none;
}
/* The card takes the pointer back, so CANCEL is clickable while the scrim is not —
 * a full-screen pointer trap over a lobby is how a player loses the LEAVE button. */
.lobby-countdown-card {
  pointer-events: auto;
  display: grid; justify-items: center; gap: clamp(6px, 1.2vh, 14px);
  padding: clamp(24px, 4vh, 48px) clamp(32px, 6vw, 80px);
  text-align: center;
}
.lobby-countdown-label {
  margin: 0;
  font-family: var(--display);
  font-size: clamp(13px, 1.5vw, 18px);
  letter-spacing: 0.08em;
  color: var(--ink-dim);
}
.lobby-countdown-num {
  margin: 0;
  font-family: var(--display);
  /* Tabular figures, so ten does not shove the card wider than nine and the digit
   * does not shift under itself once a second. */
  font-variant-numeric: tabular-nums;
  font-size: clamp(84px, 18vw, 220px);
  line-height: 0.9;
  color: var(--ink);
}
.lobby-countdown-sub { margin: 0; max-width: 46ch; }
/* THE PULSE IS RE-ARMED PER SECOND BY THE TICKER, not run as an infinite loop.
 * An `animation: … infinite` free-runs on each machine's own clock and would drift
 * out of step with the number it is decorating within a few seconds — the local
 * timer defect, restated in CSS. js/net/lobby-ui.js re-stamps `data-beat` every time
 * the derived digit changes, which retriggers this exactly on the shared boundary. */
.lobby-countdown[data-beat] .lobby-countdown-num { animation: cd-beat 620ms ease-out; }
@keyframes cd-beat {
  0%   { transform: scale(1.16); opacity: 0.55; }
  55%  { transform: scale(1); opacity: 1; }
  100% { transform: scale(1); opacity: 1; }
}
/* The final five, which are the ones that speak. The scrim closes in and the digit
 * grows, so the change of register lands on the same second the announcer does
 * rather than on a count of its own.
 *
 * NOT `--gold`. This file's own palette note gives the accent exactly one job —
 * the primary action and the current selection — and a countdown digit is neither.
 * The escalation is carried by the scrim and the scale, which cost no colour. */
.lobby-countdown[data-state="final"] { background: rgba(4, 6, 8, 0.88); }
/* THE RING GROWS, THE DIGIT FOLLOWS. It used to be the other way round and the
 * number climbed out of the circle.
 *
 * These two rules are `.lobby-countdown[data-state] .lobby-countdown-num`, which
 * outranks the plain `.lobby-countdown-num` that draws the ring further down this
 * file — so they overrode its `font-size` to as much as 260px while its `width` and
 * `height` stayed at the 132px the ring is painted into. A digit twice the diameter
 * of the circle it sits in. At zero the same box was asked to hold the word STARTING
 * at up to 88px, which is worse, because a word is wide.
 *
 * So the state sets ONE number — the ring's diameter — and the ring rule derives the
 * type from it. The escalation is intact and deliberately large; it just takes the
 * circle with it. STARTING drops the ring instead of overflowing it: the ring holds a
 * digit, and a nine-character word is not a digit. */
.lobby-countdown[data-state="final"] .lobby-countdown-num { --cd-ring: clamp(200px, 38vw, 420px); }
.lobby-countdown[data-state="starting"] .lobby-countdown-num {
  width: auto;
  height: auto;
  border-radius: 0;
  background: none;
  font-size: clamp(38px, 7vw, 88px);
  letter-spacing: 0.08em;
}
@media (prefers-reduced-motion: reduce) {
  .lobby-countdown[data-beat] .lobby-countdown-num { animation: none; }
}

/* ============================== 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 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(61, 214, 198, 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.08em;
  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.08em;
  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;
  /* Never pure black. The veil only hides the intro camera cut; renderer failures and
     gameplay effects are not allowed to impersonate a HUD-over-black world. */
  background: #101820;
  opacity: 0;
  pointer-events: none;
  z-index: calc(var(--z-hud) - 1);
}

/* ============================== device gate ==============================
 * Blocks only machines with neither a mouse path nor a touch surface.
 * Phones/tablets play via js/touch-controls.js. See js/device-gate.js.
 */
html.device-blocked #menu,
html.device-blocked #classes,
html.device-blocked #options,
html.device-blocked #lobby,
html.device-blocked #host-setup,
html.device-blocked #join-modal,
html.device-blocked #fl-social,
html.device-blocked #fl-best-fs,
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. */
  /* THE SIDES SHARE WHATEVER THE PLATE IS WIDER THAN ITS CONTENT, and that is the second
   * half of "the score card is not centered any more".
   *
   * The visual owner sets `min-width: min(620px, 62vw)` on this element — deliberately, so
   * the scrim is a plate rather than a shrink-wrap round the numbers. Three FIXED tracks
   * summing 404 px inside a 620 px box pack to the left under the default
   * `justify-content: normal`, so 216 px of box sat empty on the right and #match-timer —
   * the middle track, the thing the whole plate is symmetric about — landed 108 px left of
   * the screen centre. Measured at 1280 wide: clock centre 532.1, screen centre 640.
   *
   * `minmax(<the old clamp>, 1fr)` keeps the old width as the FLOOR and lets both sides
   * take an equal share of the surplus. Symmetric by construction, so the clock is centred
   * for any plate width, and a long faction or player name gets the extra room instead of
   * ellipsising early. */
  grid-template-columns:
    minmax(clamp(96px, 10.5vw, 148px), 1fr)
    clamp(58px, 6vw, 78px)
    minmax(clamp(96px, 10.5vw, 148px), 1fr);
  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);
}
/* `min-width: 0`, AND IT IS THE WHOLE OF "the score card is not centered any more".
 *
 * A grid ITEM defaults to `min-width: auto`, which resolves to its MIN-CONTENT width — and
 * `.sh-label` is `white-space: nowrap`, so the min-content of this box is the full label
 * string. The parent's `clamp(96px, 10.5vw, 148px)` track could not hold it back: the
 * track grew instead. Measured at 1280 wide with George's own reading on screen, the
 * header came out 620 px against the 403 its three tracks describe, and `#match-timer` —
 * the middle track, which is the thing the whole plate is meant to be symmetric about —
 * sat at x 532 with the screen centre at 640.
 *
 * The ellipsis on `.sh-label` was written for exactly this case and had never once fired,
 * because nothing was ever narrower than the text. With the floor at 0 the tracks hold,
 * the clock is centred by construction, and a long name finally truncates. Any long
 * faction name did this too — see the NORTHSTAR note above, which describes the symptom
 * that was supposed to have been fixed then. */
.sh-side {
  display: grid; grid-template-columns: minmax(0, 1fr) auto;
  align-items: baseline; gap: 4px 8px;
  min-width: 0;
}
.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 .sh-label,
#score-header.ffa .sh-side.right .sh-label { color: var(--gold); }
#score-header.ffa .sh-side .sh-score,
#score-header.ffa .sh-side.right .sh-score { color: var(--gold); }
#score-header.ffa .sh-side .sh-bar { 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); }
/* KILL CONFIRMED — the live tag count, pill under the clock. Gold is the
 * diamond's own emissive colour; absolutely positioned so the score header's
 * three-column grid never has to reflow for a fourth child. */
#tags-chip {
  position: absolute; top: calc(100% + 2px); left: 50%; transform: translateX(-50%);
  font-family: var(--ui); font-size: 10px; font-weight: 700; letter-spacing: 0.08em;
  color: var(--gold); background: rgba(4, 6, 8, 0.55); border-radius: 999px;
  padding: 2px 9px; white-space: nowrap;
}
/* HARDPOINT — the live hill, pill under the clock like the KC tag chip, one
 * rung lower so the two modes never fight for the same pixel. The name is
 * the map's callout, the countdown is the rotation clock, and the colour is
 * the holder's (game.js passes the hex) — gold when nobody holds it. */
#hill-chip {
  position: absolute; top: calc(100% + 18px); left: 50%; transform: translateX(-50%);
  display: flex; gap: 6px; align-items: baseline;
  font-family: var(--ui); font-size: 10px; font-weight: 700; letter-spacing: 0.08em;
  color: var(--gold); background: rgba(4, 6, 8, 0.55); border-radius: 999px;
  padding: 2px 9px; white-space: nowrap;
}
#hill-chip-name { color: var(--ink-1); letter-spacing: 0.12em; }
#hill-chip-left { font-variant-numeric: tabular-nums; }
/* PRACTICE AUTO LOCK — the assist mode, pill under the clock like the KC tag
 * chip, one rung lower than the hill chip so the three never fight for the
 * same pixel. Absolute like the siblings: the score header's grid never
 * reflows for it. Hidden when None (the assist is off and the HUD says so). */
#autolock-chip {
  position: absolute; top: calc(100% + 34px); left: 50%; transform: translateX(-50%);
  font-family: var(--ui); font-size: 10px; font-weight: 700; letter-spacing: 0.08em;
  color: var(--gold); background: rgba(4, 6, 8, 0.55); border-radius: 999px;
  padding: 2px 9px; white-space: nowrap;
}
#autolock-chip-mode { color: var(--ink-1); letter-spacing: 0.12em; }

/* --- radar ---
 * Top-left, where MW2019 and MW2 2009 both put it. Heading-up and player-centred: the
 * map turns underneath a chevron that is nailed to the middle. See the long note on
 * js/hud.js's minimap section for why this arrangement was wrong once and is right now —
 * the short version is that it failed on ZOOM, not on direction, and the zoom is fixed
 * at a distance now instead of derived from the map's size. */
#minimap {
  position: absolute; left: 20px; top: 18px;
  /* A CIRCLE, AND THAT IS STRUCTURAL. A rotating map inside a rectangle reaches further
   * through the corners than across the axes, and which world the corners reach changes
   * as you turn, so the dial's range stops being a number anyone can learn. The circle is
   * the only rotation-invariant frame. Square while the map was locked; round now that it
   * moves; both times for the same reason. */
  width: clamp(150px, 13.5vw, 214px); aspect-ratio: 1; height: auto;
  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. The
   * face is a shade cooler than the map's ground fill so the map's silhouette reads
   * against it — which, on a player-centred dial, is the only thing that says where the
   * world stops. */
  background: radial-gradient(circle, rgb(15, 20, 26) 0%, rgb(11, 15, 20) 68%, rgb(7, 10, 13) 100%);
  box-shadow:
    inset 0 0 0 1px rgba(150, 190, 220, 0.30),
    inset 0 0 15px rgba(4, 8, 12, 0.58),
    0 2px 12px rgba(0, 0, 0, 0.42);
  overflow: hidden;
  /* The plate under it is a big composited layer that turns every frame. Promoting the
   * clip container keeps the rounded-corner mask off the paint path. */
  contain: paint;
}
/* THE RANGE RINGS ARE BACK, AND THEY ARE TRUE AGAIN. They were switched off for the
 * map-locked version and the reason was sound: two circles around the centre of a fixed
 * floorplan read as "half range" and "three quarters range" measured from the player,
 * and the player was somewhere else entirely. The centre is the player again, so a ring
 * IS a distance — these are 1/3 and 2/3 of MM_RANGE_M, about 11 m and 23 m. */
#minimap-rings {
  position: absolute; inset: 0; pointer-events: none;
}
#minimap-rings i {
  position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
  border-radius: 50%; border: 1px solid rgba(150, 190, 220, 0.13);
}
#minimap-rings i:nth-child(1) { width: 33.3%; height: 33.3%; }
#minimap-rings i:nth-child(2) { width: 66.6%; height: 66.6%; }
/* The compass ring. Each mark is a radius line from the centre with its tick drawn at
 * the far end, so placing one is a single rotation and the ring itself is one more. */
#minimap-compass {
  position: absolute; inset: 0; pointer-events: none;
  transform-origin: 50% 50%;
}
/* EACH MARK IS A FULL-SIZE BOX TURNED ABOUT ITS OWN CENTRE, with the tick drawn at its
 * top edge. The obvious construction — a zero-width radius line from the centre to the
 * rim, turned about its bottom end — measured wrong in the browser: the boxes came back
 * 66.9 x 53.1 instead of 0 x 85.5 and all four marks landed on the diagonals. A box that
 * is exactly the widget cannot be got wrong that way: `inset: 0` and `50% 50%` are the
 * same two numbers whatever the dial's size, and the rotation is then plainly the
 * bearing. */
#minimap-compass i {
  position: absolute; inset: 0;
  transform-origin: 50% 50%;
}
#minimap-compass i:nth-child(1) { transform: rotate(0deg); }
#minimap-compass i:nth-child(2) { transform: rotate(90deg); }
#minimap-compass i:nth-child(3) { transform: rotate(180deg); }
#minimap-compass i:nth-child(4) { transform: rotate(270deg); }
#minimap-compass i::before {
  content: ''; position: absolute; left: 50%; top: 4px;
  transform: translateX(-50%);
  width: 2px; height: 6px; border-radius: 1px;
  background: rgba(178, 206, 228, 0.45);
}
/* North is the one mark that has to be findable in a tenth of a second, so it is the one
 * warm colour on an otherwise cold instrument — and it is the LETTER, not a triangle: a
 * triangle survives rotation without counter-rotating, which is why it was chosen, but it
 * left a player asking what it meant. js/hud.js pays the counter-rotation instead. */
#minimap-compass i.n::before { display: none; }
#minimap-compass i.n b {
  position: absolute; left: 50%; top: 1px;
  transform-origin: 50% 50%;
  /* The glyph's own box is centred on the tick position; the inline transform js/hud.js
   * writes turns it about that centre, so `translate(-50%, 0)` cannot live here — it
   * would be overwritten. The negative margin does the centring instead. */
  margin-left: -5px;
  width: 10px; text-align: center;
  font-family: var(--num); font-weight: 700; font-size: 11px; line-height: 11px;
  color: var(--gold, #f0b429);
  text-shadow: 0 0 3px rgba(0, 0, 0, 0.95), 0 1px 2px rgba(0, 0, 0, 0.8);
}
/* Contacts ride the SAME transform as the plate — they are children of a wrapper that
 * gets it — so a dot cannot disagree with the map it is drawn on. `transform-origin` at
 * the corner because the wrapper's local origin is the map's own (viewMinX, viewMinZ). */
#minimap-dots {
  position: absolute; left: 0; top: 0; width: 0; height: 0;
  transform-origin: 0 0;
}
.mm-dot {
  position: absolute; left: -3.5px; top: -3.5px; width: 7px; height: 7px;
  border-radius: 50%; background: var(--blue);
  box-shadow: 0 0 0 1px rgba(6, 10, 14, 0.85), 0 0 5px rgba(107, 194, 255, 0.55);
  transition: opacity 160ms linear;
}
/* ROUND, NOT SQUARE. The enemy blip used to be a 1 px-radius square, which was a fine
 * second channel next to colour on a plate that never moved; on one that turns, the
 * square turns with it and reads as a diamond half the time. A ring is the same second
 * channel and is rotation-invariant. */
.mm-dot.enemy {
  background: var(--red);
  box-shadow: 0 0 0 1px rgba(6, 10, 14, 0.85), 0 0 0 2px rgba(255, 90, 78, 0.35), 0 0 6px rgba(255, 90, 78, 0.5);
}
.mm-dot.me { display: none; }
/* Own position and facing. PINNED TO THE CENTRE and never turned: the whole point of
 * heading-up is that the chevron is the fixed thing. js/hud.js writes no transform to
 * either of these — it clears any it finds, because HUD outlives a match. */
#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%);
  /* A white chevron over the 0.92-alpha near-white that tall geometry is drawn in needs
   * its own edge. `drop-shadow` follows the clip path, which a border would not. */
  filter: drop-shadow(0 0 1px rgba(6, 9, 11, 0.95)) drop-shadow(0 1px 2px rgba(6, 9, 11, 0.6));
  z-index: 2;
}
#minimap-cone {
  /* INVERTED, BY REQUEST, WITH TWO REFERENCE PICTURES. The old cone was a border-trick
   * triangle in one flat 7% white -- on the tilted plate it read as a dark SHADE laid
   * over the direction you face. The reference the user supplied is the opposite: a
   * radar BEAM, brightest at the player and falling off with distance. Same footprint
   * (34px half-width, 52px reach), but a real box clipped to the triangle so the fill
   * can be a gradient; a border triangle cannot fade. */
  /* Width and height are mirrored in js/hud.js as MM_CONE_W/MM_CONE_H. The apex is the
   * box's bottom centre, and it is placed ON the widget's centre by these margins, so the
   * beam points straight up out of the chevron — which is where the player is looking,
   * every frame, because the map is what turns. */
  position: absolute; left: 50%; top: 50%;
  margin-left: -34px; margin-top: -52px;
  width: 68px; height: 52px;
  clip-path: polygon(50% 100%, 0% 0%, 100% 0%);
  background: linear-gradient(to top, rgba(186, 226, 255, 0.34), rgba(186, 226, 255, 0.02));
  z-index: 1;
}
#minimap-map {
  /* NO `width: 100%`. The canvas is the WHOLE MAP at the dial's scale, which is usually
   * several times the widget, and the widget clips it — that is what a player-centred
   * view is. Stretching it to the widget is the map-locked arrangement and would put
   * back the zoom that made the first heading-up version unreadable. js/hud.js sets the
   * pixel size from the measured dial; `transform-origin` is the corner because the
   * canvas's own origin is the map's (viewMinX, viewMinZ). */
  position: absolute; left: 0; top: 0;
  /* ZERO UNTIL JS SIZES IT. The backing store is up to 1024 px on its long axis and the
   * widget is about 170, so an intrinsically-sized canvas would paint one frame of a map
   * at six times the intended zoom between `buildMinimapGeometry` and the first
   * `setMinimapView`. Nothing is drawable before there is a player position anyway. */
  width: 0; height: 0;
  transform-origin: 0 0;
  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. */
}
#minimap.radar-off { visibility: hidden; }

/* Match-only TAB / T / M chips. Top-right, same corner as cheat/preset badges.
   Stay up for the whole match. Badges push them down via cheats.css :has(). */
#hud-hints {
  position: absolute;
  top: 18px;
  right: 20px;
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 5px;
  pointer-events: none;
  z-index: 41;
}
html.fl-touch #hud-hints,
html.fl-touch-playing #hud-hints { display: none; }
.hud-hint {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 3px 8px 3px 10px;
  background: rgba(8, 10, 12, 0.72);
  box-shadow: inset 0 0 0 1px rgba(232, 234, 236, 0.16);
  font-family: var(--ui);
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-2);
  text-shadow: var(--hud-shadow);
}
.hud-hint kbd {
  font-family: var(--mono);
  font-size: 10px;
  letter-spacing: 0.06em;
  padding: 1px 6px;
  background: rgba(232, 234, 236, 0.12);
  box-shadow: inset 0 0 0 1px rgba(232, 234, 236, 0.22);
  color: var(--ink);
}

/* --- 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 forwards; }
#hitmarker.show.kill { animation-duration: 340ms; }
#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 --- */
/* THE ELIMINATION BANNER. Top centre, under the score header and its chips.
 *
 * *"i want to change that to be bigger and more promonent on top of the screen, think of
 * a good way to do it without being annonyng or overbearing."*
 *
 * The two constraints pull against each other, so they are answered separately: BIG is
 * the type and the position, UNOBTRUSIVE is the footprint. This is a centred lockup with
 * no panel, no border and no full-width bar — a soft vertical wash behind it so it holds
 * over a bright wall, and nothing else. It never touches the crosshair, never covers the
 * body you just shot, and it is gone in 1.4 s.
 *
 * `top` is clamped rather than anchored to `#score-header` because the header's chips
 * (`#hill-chip`, `#autolock-chip`) hang off its bottom in some modes and not others, and
 * a banner whose position depends on the game mode is a banner that lands on a chip. */
#kill-banner {
  position: absolute; left: 50%; top: clamp(96px, 12vh, 150px);
  transform: translateX(-50%);
  display: flex; align-items: baseline; gap: clamp(10px, 1.1vw, 18px);
  padding: 6px 22px 9px;
  white-space: nowrap; pointer-events: none;
  background: radial-gradient(ellipse at 50% 50%, rgba(6, 9, 12, 0.66), rgba(6, 9, 12, 0) 72%);
  animation: kbPop 1400ms var(--ease) forwards;
}
#kill-banner-label {
  font-family: var(--ui); font-weight: 700;
  font-size: clamp(15px, 1.75vw, 25px);
  /* 0.12em is the house ceiling — `frontline-identity` fails the build above it, and the
   * reason is worth respecting rather than arguing with: this is a field terminal, not a
   * fashion masthead. The width comes from the type SIZE instead. */
  letter-spacing: 0.115em; text-indent: 0.115em;   /* tracking adds a trailing gap; this pays it back */
  color: var(--ink-1);
  text-shadow: 0 2px 6px rgba(0, 0, 0, 0.85);
}
#kill-banner-score {
  font-family: var(--num); font-weight: 700;
  font-size: clamp(21px, 2.4vw, 35px);
  color: var(--gold);
  font-variant-numeric: tabular-nums;
  text-shadow: 0 2px 6px rgba(0, 0, 0, 0.85);
}
/* The rule under the lockup wipes out from the centre. It is the only motion in the
 * banner that is not a fade, and it is what makes the thing read as an EVENT rather than
 * as a label that happened to appear. */
#kill-banner::after {
  content: ''; position: absolute; left: 50%; bottom: 0;
  width: 100%; height: 1px; transform: translateX(-50%) scaleX(0);
  background: linear-gradient(to right, transparent, var(--gold), transparent);
  animation: kbRule 1400ms var(--ease) forwards;
}
@keyframes kbPop {
  0%   { opacity: 0; transform: translateX(-50%) translateY(-9px); }
  9%   { opacity: 1; transform: translateX(-50%) translateY(0); }
  74%  { opacity: 1; transform: translateX(-50%) translateY(0); }
  100% { opacity: 0; transform: translateX(-50%) translateY(-5px); }
}
@keyframes kbRule {
  0%   { transform: translateX(-50%) scaleX(0); opacity: 0; }
  16%  { transform: translateX(-50%) scaleX(1); opacity: 0.9; }
  74%  { transform: translateX(-50%) scaleX(1); opacity: 0.9; }
  100% { transform: translateX(-50%) scaleX(1); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  #kill-banner { animation: kbFade 1400ms linear forwards; }
  #kill-banner::after { animation: kbFade 1400ms linear forwards; transform: translateX(-50%) scaleX(1); }
}
@keyframes kbFade { 0%, 88% { opacity: 1; } 100% { opacity: 0; } }

#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-medal { display: flex; align-items: center; gap: 4px; font-size: 9px; letter-spacing: 0.08em; 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(61, 214, 198, 0.22); color: #e6ebe8; }
.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(230, 235, 232, 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: flFade 160ms var(--ease) 1;
}

#hud .mission-status {
  position: absolute;
  top: 92px;
  left: 50%;
  z-index: 19;
  display: grid;
  min-width: 260px;
  padding: 9px 16px 10px;
  transform: translateX(-50%);
  border-top: 2px solid #d7a737;
  background: linear-gradient(90deg, transparent, rgb(8 13 18 / 88%) 12%, rgb(8 13 18 / 88%) 88%, transparent);
  color: #fff;
  text-align: center;
  pointer-events: none;
}
#hud .mission-status strong { font-size: 13px; letter-spacing: 0.08em; }
#hud .mission-status span { margin-top: 3px; color: #d1d3d2; font-size: 11px; letter-spacing: .05em; }
/* 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.08em;
  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
 */
/* THE BOTTOM-RIGHT COLUMN'S FIRST RUNG, NAMED ONCE.
 *
 * This arithmetic is the weapon block's own stack measured out, and it now has a second
 * reader — the medal stack sits above the streak, which sits above the gun stats. Two
 * copies of a seven-term calc is two places to update and one place to forget, and
 * AGENTS.md asks for the relationship rather than the literal. Declared on `#hud` because
 * every element in the column is a child of it. */
#hud { --hud-br-rung1: calc(18px + 17px + 46px + 20px + 34px + 20px + 14px); }
#streak {
  position: absolute; right: 20px;
  bottom: var(--hud-br-rung1);
  text-align: right;
  font-size: 15px; color: var(--gold); letter-spacing: 0.08em;
  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. 
 * 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.08em;
  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: 11px; font-weight: 700; letter-spacing: 0.08em; color: var(--gold);
  margin-bottom: 5px; animation: pulse 0.9s steps(2) infinite;
  text-shadow: 0 1px 10px rgba(0,0,0,0.55);
}
#weapon-block:has(#ammo.empty) #low-ammo {
  color: var(--red);
}
/* 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.08em; color: var(--ink-3); }
/* GUN GAME — the next rung under the current weapon. Gold like the ladder's
 * own chip colour, small enough to sit under the ammo block without owning
 * the corner: it is a prompt ("three kills from the Intervention"), not a
 * second readout competing with the weapon name. */
#next-weapon {
  display: flex; justify-content: flex-end; gap: 6px; margin-top: 5px;
  font-family: var(--ui); font-size: 10px; font-weight: 700; letter-spacing: 0.08em;
  color: var(--gold); text-transform: uppercase;
}
#next-weapon-name { color: var(--ink-1); }
/* 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: 16px; margin-top: 8px;
  font-family: var(--ui); font-size: 10.5px; font-weight: 600; letter-spacing: 0.12em; color: var(--ink-2);
  line-height: 1.35;
  padding-bottom: 2px;
}
#equipment .equip-slot {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 2px;
  min-width: 0;
}
#equipment .equip-hint {
  font-size: 9px;
  font-weight: 700;
  letter-spacing: 0.08em;
  color: var(--gold);
  text-transform: uppercase;
  opacity: 0.9;
}
#equipment .equip-hint.hidden {
  display: none !important;
}
#equipment .equip-slot:has(.spent) .equip-hint {
  display: none !important;
}
/* 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.08em; color: var(--ink-2); }
#spawnin-team { font-size: clamp(24px, 2.8vw, 32px); letter-spacing: 0.08em; 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.08em; }
  100% { opacity: 1; transform: scale(1); letter-spacing: 0; }
}
#spawnin-map { font-family: var(--ui); font-size: 13px; font-weight: 600; letter-spacing: 0.08em; 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-hint { font-family: var(--ui); font-size: 12px; font-weight: 600; letter-spacing: 0.08em; 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 */
#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: 58%; 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);
  z-index: 30;
  max-height: min(52vh, 440px);
  overflow-y: auto;
}
/* Sticky Tab scoreboard sits top-right. When both are up, park the death
 * attackers panel lower on the right rail so it is not clipped under the board. */
#hud:has(> #scoreboard:not(.hidden)) #death-panel {
  top: auto;
  bottom: clamp(110px, 16vh, 190px);
  transform: none;
  max-height: min(38vh, 320px);
}
/* 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.08em;
  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.
 *
 * THE FRAME DOES THAT JOB. THE DARKNESS WAS DOING IT INSTEAD, AND BADLY.
 *
 * *"when you get killed, the kill cam when it shifts to what the enemey sees of you, the
 * screen turns dark almost to the point whree yo ucan't see anyting. i think tis better
 * if you jsut make it normal color or brightness and maybe do some other effect like a
 * red border around the screen or something."*
 *
 * It was `inset 0 0 140px rgba(0, 0, 0, 0.6)` — a 140 px black wash pulled in from all
 * four edges at 60% — over the map's own authored vignette, over a 2 px hairline nobody
 * could see. On a 720-tall frame the two vertical washes very nearly meet, so the picture
 * was dark almost everywhere and the one signal that said "this is a replay" was the part
 * of it you could not read.
 *
 * Worth stating that the RENDER was never the problem, because that is where I looked
 * first: `_beginKillcamForInner` calls `_applyPostState(0, 1, 0)` — health01 = 1, so the
 * hurt grade is fully off — and the per-frame killcam path passes 1 as well. The frame the
 * renderer produces during a killcam is an ordinary one. All of it was here.
 *
 * So: the wash drops to a light edge that shapes the frame without hiding it, and the
 * REPLAY signal becomes the border itself, in --red. Red is this HUD's danger token and
 * this is the replay of your own death, which is the one place it is literally true; it is
 * also the one colour that cannot be confused with the cyan the live HUD is built from. */
#killcam::before {
  content: ''; position: absolute; inset: 0;
  box-shadow:
    inset 0 0 0 3px rgba(228, 89, 108, 0.62),
    inset 0 0 26px rgba(228, 89, 108, 0.16),
    inset 0 0 110px rgba(0, 0, 0, 0.26);
}
.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.08em;
  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.08em;
  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.08em;
  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.08em; 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 
 * 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.
 */
/* Live scoreboard is a narrow sticky side rail (top-right). Endboard stays centered. */
#scoreboard {
  position: absolute;
  right: clamp(10px, 1.6vw, 18px);
  top: clamp(54px, 8vh, 88px);
  left: auto;
  transform: none;
  width: min(280px, calc(100vw - 24px));
  max-height: min(72vh, calc(100vh - 120px));
  overflow-y: auto;
  overflow-x: hidden;
  background: rgba(6, 8, 10, 0.92);
  padding: 0;
  box-shadow: 0 18px 48px rgba(0, 0, 0, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.07);
  border-radius: 10px;
  z-index: 40;
  pointer-events: none;
}
#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.82);
  -webkit-backdrop-filter: blur(30px);
  backdrop-filter: blur(30px);
}
#scoreboard {
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.14),
    inset 0 0 0 1px rgba(255, 255, 255, 0.06),
    0 16px 40px rgba(0, 0, 0, 0.5);
}
#endboard {
  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);
  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); }
}

/* Side scoreboard denser type + compact columns */
#scoreboard .sb-title {
  padding: 10px 12px 0;
}
#scoreboard h3 {
  font-size: 13px;
  letter-spacing: 0.08em;
  line-height: 1.2;
}
#scoreboard .sb-head,
#scoreboard .sb-row {
  padding-left: 12px;
  padding-right: 12px;
  gap: 6px;
}
#scoreboard .sb-head {
  margin-top: 10px;
  padding-top: 7px;
  padding-bottom: 7px;
  font-size: 8.5px;
  letter-spacing: 0.12em;
}
#scoreboard .sb-row {
  font-size: 12px;
  padding-top: 6px;
  padding-bottom: 6px;
}
#scoreboard .sb-row .sb-n:first-of-type { font-size: 12.5px; }
#scoreboard .sb-name { letter-spacing: 0.04em; }
#scoreboard .sb-team {
  margin-top: 10px;
  padding: 0 12px 6px;
  font-size: 10px;
  letter-spacing: 0.08em;
  gap: 8px;
}
#scoreboard .sb-foot {
  margin: 8px 12px 10px;
  font-family: var(--ui);
  font-size: 9.5px;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--ink-3);
  text-align: right;
}
#scoreboard .sb-foot kbd {
  font-family: var(--ui);
  font-size: 9px;
  letter-spacing: 0.08em;
  padding: 1px 5px;
  border: 1px solid rgba(255,255,255,0.16);
  border-radius: 3px;
  color: var(--ink-2);
}
#scoreboard .sb-map {
  margin: 0 0 4px;
  font-family: var(--display);
  font-size: 15px;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--ink);
}
#scoreboard .sb-map:empty { display: none; }
#scoreboard .key-sheet {
  list-style: none;
  margin: 10px 12px 4px;
  padding: 0;
  columns: 1;
  font-family: var(--ui);
  font-size: 12px;
  line-height: 1.85;
  letter-spacing: 0.04em;
  color: var(--ink-2);
}
#scoreboard .key-sheet li { break-inside: avoid; }
#scoreboard .key-sheet kbd {
  font-family: var(--mono);
  font-size: 10px;
  letter-spacing: 0.06em;
  padding: 1px 5px;
  margin-right: 2px;
  border: 1px solid rgba(255,255,255,0.16);
  border-radius: 3px;
  color: var(--ink);
}
  color: var(--ink-2);
}
.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) 34px 28px 28px 28px; }
.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.08em;
  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(61, 214, 198, 0.15); }
.sb-row.me .sb-name { color: var(--gold); }
/* FFA boards: rank ordinal instead of team edge tints. */
.sb-row.ffa .sb-name { display: inline-flex; align-items: baseline; gap: 8px; }
.sb-rank {
  display: inline-block;
  min-width: 1.4em;
  font-family: var(--num);
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.04em;
  color: var(--ink-3);
  opacity: 0.85;
}
.sb-row.me .sb-rank { color: var(--gold); opacity: 1; }
.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.08em;
  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.08em; opacity: 0.72; }
.sb-bot {
  font-style: normal; font-size: 8.5px; font-weight: 700; letter-spacing: 0.08em;
  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 { width: min(1560px, 100%); margin-inline: 0; }
  .sheet { width: min(1560px, 100%); }
  .sheet-foot { width: 100%; padding-inline: calc((100% - 1560px) / 2 + clamp(22px, 5vw, 78px)); }
}

/* 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-mark .bm-front, .brand-mark .bm-line { font-size: clamp(56px, 8vw, 112px); }
  .tagline { margin-top: 10px; }
  .menu-main { max-width: 520px; }
  /* THE CAP COMES OFF WHEN THE PANEL STOPS BEING A SIDEBAR. Stacked under the
   * nav it is the last thing on the page and the page's own scroll reaches it;
   * a nested scroller here would mean two scrollbars racing on a phone, and the
   * outer one wins on a touch drag that starts anywhere but inside the panel. */
  .menu-setup { max-width: 620px; max-height: none; overflow-y: visible; }
  /* 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); }
  .sheet-foot {
    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-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) {
  .opt-grid { grid-template-columns: minmax(0, 1fr); }
  .sheet-head { align-items: flex-start; }
}

/* Small window / phone-shaped viewport. Touch play is landscape-oriented
 * (see .fl-touch rules below); this branch still covers a narrow desktop window. */
@media (max-width: 620px) {
  .brand-mark .bm-front, .brand-mark .bm-line { font-size: clamp(40px, 13vw, 64px); }
  .menu-main .btn.primary { font-size: clamp(15px, 3.6vw, 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(16px, 2.4vh, 28px); gap: clamp(10px, 1.8vh, 20px); }
  .brand-mark .bm-front, .brand-mark .bm-line { 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; }
  .sheet-foot { 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) 30px 26px 26px 26px; }
  #scoreboard { width: min(240px, calc(100vw - 20px)); top: 48px; right: 8px; }
  /* 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: 6px;
}
.ars-head .panel-label { margin: 0; }
/* 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: 10px; }

/* ---------------------------------------------------------------------------
   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: 0.08em; 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: 0.08em;
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}

/* ==================== 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. */
/* ---------------------------------------------------------------------------
 * MOVED OUT OF THE TOP-CENTRE LANE, AND THE THING THAT PUSHED IT IS MINE.
 *
 * *"our badge positions sometimes interfer with things especially your new eliminted
 * popup. lets put them above the gun stats box bottom right hand corner. have them appear
 * there and you can make them bigger also so they stand out."*
 *
 * Measured, because "sometimes" deserves better than a guess: this stack sat at
 * top 76px and is about 52px tall, so it owned y 76..128. The elimination banner added in
 * the previous round sits at `clamp(96px, 12vh, 150px)` — y 96..158 on a 720-tall window.
 * They overlap by 32px, and they fire AT THE SAME INSTANT, because a medal and the
 * elimination award are two consequences of one kill. Every kill that earned a medal drew
 * one on top of the other.
 *
 * The top-centre placement was itself a user request — *"im kidn of a fan of medals being
 * top center"* — so this is a reversal, and the reason it is the right one is that the
 * request it served has been overtaken by a later one for the same lane. What that ruling
 * actually asked for is preserved: the medals still fade in and out, still queue, still
 * re-centre with a FLIP, and still never overlap another HUD element. Only the corner
 * changed, and it changed to the corner George named.
 *
 * BOTTOM-RIGHT, ABOVE THE STREAK, WHICH IS ABOVE THE GUN STATS. Right-anchored, so a
 * second and third medal grow LEFT into open frame rather than toward the weapon block.
 * `align-items: flex-end` so the caption baselines line up along the bottom whatever the
 * art height is.
 * --------------------------------------------------------------------------- */
#medal-stack {
  position: absolute;
  right: 20px;
  bottom: calc(var(--hud-br-rung1) + 30px);
  display: flex;
  flex-direction: row;
  align-items: flex-end;
  justify-content: flex-end;
  gap: clamp(10px, 1.4vw, 22px);
  /* Never so wide it reaches the killfeed on the other side of the frame. */
  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(12px, 1.15vw, 16px);
  font-weight: 700;
  letter-spacing: 0.08em;
  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. */
  /* RAISED FOR THE NEW CORNER — *"you can make them bigger also so they stand out"*. The
   * top-centre lane was a 89px gap between the score header and `#banner` and the icon had
   * to fit inside it; the bottom-right column has the whole frame above it, so the ceiling
   * comes off. The vmin floor is kept for the reason the original note gives: a short
   * window has to shrink the badge as well as a narrow one. */
  width: clamp(42px, 6.4vmin, 74px);
  height: clamp(42px, 6.4vmin, 74px);
  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: flSpin 900ms linear infinite; }
}
@keyframes flSpin { 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%; }

/* ==========================================================================
   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;
}


/* ---- Front Line lobby identity + host/join flows ---- */
.lobby-name-tag {
  display: block;
  font-family: var(--display);
  font-size: clamp(22px, 2.6vw, 34px);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink);
  line-height: 1.05;
  max-width: min(42vw, 420px);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.lobby-code-tag {
  font-size: 12px !important;
  letter-spacing: 0.08em;
  color: var(--ink-3);
  opacity: 0.9;
}
.lobby-vis-tag {
  display: inline-block;
  margin-top: 6px;
  font-family: var(--ui);
  font-size: 10px;
  font-weight: 700;
  letter-spacing: 0.08em;
  padding: 3px 8px;
  border-radius: 999px;
  border: 1px solid rgba(255,255,255,0.12);
  color: var(--ink-2);
}
.lobby-vis-tag[data-vis="private"] {
  color: var(--gold);
  border-color: rgba(61, 214, 198,0.35);
}
.lobby-brief {
  min-width: 0;
  overflow-x: hidden;
}
.lobby-wrap {
  /* Prefer wider deployment rail so map/mode rows do not force horizontal scroll */
  grid-template-columns: minmax(280px, 1.15fr) minmax(0, 1.1fr) minmax(0, 0.95fr);
}
.host-setup-body, .join-manual { max-width: 520px; }

/* Host / Join sit ON the main menu. The rotating map plate and the score stay
 * with `#menu`; these dialogs are a translucent scrim + a compact sheet. */
#host-setup.modal,
#join-modal.modal,
#options.modal {
  background: rgba(4, 6, 8, 0.58);
  align-content: safe center;
  justify-items: center;
}
#host-setup.modal .sheet,
#join-modal.modal .sheet,
#options.modal .sheet {
  height: auto;
  min-height: 0;
  width: min(560px, 100%);
  max-height: min(92dvh, 920px);
  overflow: auto;
  margin: 0;
  background: rgba(16, 20, 18, 0.94);
}
#join-modal.modal .sheet { width: min(680px, 100%); }
#options.modal .sheet { width: min(1100px, 100%); max-height: min(92dvh, 960px); }
#fl-best-fs.modal { background: rgba(4, 6, 8, 0.58); }
#fl-best-fs.screen.modal { z-index: calc(var(--z-toast) + 2); }
#fl-best-fs.screen.modal[data-over="load"] { z-index: calc(var(--z-loading) + 1); }

.fl-social {
  position: fixed;
  top: max(12px, env(safe-area-inset-top));
  right: max(12px, env(safe-area-inset-right));
  z-index: calc(var(--z-screen) + 5);
  pointer-events: none;
}
.fl-social.hidden { display: none !important; }
.fl-social-chip {
  pointer-events: auto;
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 6px 6px 6px 12px;
  background: rgba(10, 14, 12, 0.78);
  border: 1px solid var(--rule-strong);
}
.fl-social-chip #fl-online-count {
  font-family: var(--ui);
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-2);
  white-space: nowrap;
}
.fl-social-chip .btn { min-height: 34px; padding: 8px 12px; font-size: 12px; }
#btn-friends[data-badge]::after {
  content: attr(data-badge);
  margin-left: 8px;
  color: var(--cta);
  font-weight: 800;
}
.fl-social-body {
  display: flex;
  pointer-events: auto;
  position: fixed;
  top: 0;
  right: 0;
  width: min(400px, 94vw);
  height: 100dvh;
  overflow: auto;
  padding: 70px 14px 18px;
  border-left: 1px solid var(--rule-strong);
  background: rgba(10, 14, 12, 0.97);
  flex-direction: column;
  gap: 10px;
  transform: translateX(105%);
  transition: transform 0.24s var(--ease);
  z-index: calc(var(--z-modal) + 2);
}
.fl-social-body[hidden] { display: none; }
.fl-social.open .fl-social-body { transform: translateX(0); }
#fl-friends-list { display: flex; flex-direction: column; gap: 8px; flex: 1; }
.fl-social-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}
.fl-social-tabs {
  display: grid;
  grid-template-columns: 1fr 1fr 1fr;
  gap: 4px;
  padding: 4px;
  border: 1px solid var(--rule);
  background: var(--s1);
}
.fl-social-tabs [role="tab"] {
  border: 0;
  background: transparent;
  color: var(--ink-2);
  font: 700 11px/1 var(--ui);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  padding: 9px 6px;
  cursor: pointer;
}
.fl-social-tabs [role="tab"][aria-selected="true"] {
  background: var(--s2);
  color: var(--ink);
}
.fl-social-tabs [role="tab"][data-badge]::after {
  content: attr(data-badge);
  margin-left: 6px;
  color: var(--cta);
}
.fl-social-row, .fl-social-add {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: 8px;
  padding: 10px;
  border: 1px solid var(--rule);
}
.fl-social-who { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.fl-social-name { font-weight: 650; }
.fl-social-meta { color: var(--ink-2); font-size: 12px; }
.fl-social-row .is-online { color: var(--green); }
.fl-social-row .is-offline { color: var(--ink-3); }
.fl-social-actions { display: flex; flex-wrap: wrap; gap: 6px; }
/* One line, under the tabs, replacing itself. Every social action used to resolve
 * into silence — see the note on `button` in js/social.js. */
.fl-social-say {
  margin: 0;
  padding: 7px 9px;
  border-left: 3px solid var(--green);
  background: var(--s1);
  color: var(--ink);
  font-size: 12px;
}
.fl-social-say[data-tone="bad"] { border-left-color: var(--red); color: var(--ink); }
.fl-social-action[data-sent="true"] { opacity: 0.72; border-color: var(--green); }
.fl-social-add input, .fl-social-compose input {
  width: 100%;
  padding: 7px;
  border: 1px solid var(--rule-strong);
  background: var(--s1);
  color: var(--ink);
  font: inherit;
}
.fl-social-compose { display: flex; gap: 6px; }
.fl-social-compose input { flex: 1; min-width: 0; }
.fl-social-thread {
  display: flex;
  flex-direction: column;
  gap: 6px;
  padding: 8px;
  border-left: 2px solid var(--rule-strong);
  background: rgba(255,255,255,0.025);
}
.fl-social-message {
  display: flex;
  flex-direction: column;
  gap: 3px;
  max-width: 92%;
  padding: 7px 9px;
  border: 1px solid var(--rule);
  background: var(--s1);
}
.fl-social-message.is-outgoing {
  align-self: flex-end;
  border-color: rgba(143, 190, 151, 0.42);
  background: rgba(61, 92, 67, 0.28);
}
.fl-social-message-body { white-space: pre-wrap; overflow-wrap: anywhere; }
.fl-social-message-time { color: var(--ink-3); font-size: 10px; }
.fl-social-action {
  border: 1px solid var(--rule-strong);
  background: var(--s2);
  color: var(--ink);
  font: 700 11px/1 var(--ui);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  padding: 7px 9px;
  cursor: pointer;
}
.fl-social-empty, .fl-social-body p { margin: 0; color: var(--ink-2); font-size: 13px; }

.host-vis { border: 0; margin: 14px 0; padding: 0; display: grid; gap: 10px; }
.host-vis-opt {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 2px 10px;
  padding: 12px 14px;
  border: 1px solid var(--rule);
  border-radius: 10px;
  background: rgba(255,255,255,0.03);
  cursor: pointer;
}
.host-vis-opt input { grid-row: 1 / span 2; margin-top: 4px; }
.host-vis-opt span { font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
.host-vis-opt i { grid-column: 2; font-style: normal; color: var(--ink-3); font-size: 12.5px; }
.join-browser { margin-bottom: 22px; }
.join-browser-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.join-lobby-list { display: grid; gap: 8px; margin-top: 10px; }
.join-lobby-row {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto auto;
  grid-template-areas: "name count vis" "meta count vis";
  gap: 2px 14px;
  text-align: left;
  padding: 12px 14px;
  border-radius: 10px;
  border: 1px solid var(--rule);
  background: rgba(8,11,14,0.55);
  color: inherit;
  cursor: pointer;
}
.join-lobby-row:hover { border-color: rgba(61, 214, 198,0.35); background: rgba(61, 214, 198,0.06); }
.jlr-name { grid-area: name; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; }
.jlr-meta { grid-area: meta; color: var(--ink-3); font-size: 12px; letter-spacing: 0.04em; }
.jlr-count { grid-area: count; align-self: center; font-family: var(--num); font-weight: 700; }
.jlr-vis { grid-area: vis; align-self: center; font-size: 10px; font-weight: 700; letter-spacing: 0.08em; color: var(--ink-3); }
.join-sheet .sheet-body { overflow: auto; }
@media (max-width: 1100px) {
  .lobby-wrap { grid-template-columns: 1fr; }
}

.join-lobby-row {
  grid-template-columns: minmax(0, 1fr) auto auto auto;
  grid-template-areas: "name count vis act" "meta count vis act";
}
.jlr-action {
  grid-area: act;
  align-self: center;
  font-family: var(--ui);
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.08em;
  color: #14100a;
  background: var(--gold);
  padding: 6px 10px;
  border-radius: 6px;
}
.join-manual { margin-top: 18px; opacity: 0.95; }
.join-manual .panel-label { margin-bottom: 8px; }

/* ============================== touch / mobile landscape ==============================
 * ClashWorld-style sticks + combat buttons. Mounted by js/touch-controls.js.
 * `html.fl-touch` is set for coarse-pointer phones/tablets (not touchscreen laptops).
 * Play is landscape-only; portrait shows the orientation gate over everything.
 */

.fl-mobile-hud,
.fl-orientation-gate {
  display: none;
}

@media (orientation: portrait) {
  html.fl-touch .fl-orientation-gate {
    position: fixed;
    inset: 0;
    z-index: calc(var(--z-gate) - 1);
    display: grid;
    place-content: center;
    justify-items: center;
    gap: 10px;
    padding:
      max(28px, env(safe-area-inset-top))
      max(24px, env(safe-area-inset-right))
      max(28px, env(safe-area-inset-bottom))
      max(24px, env(safe-area-inset-left));
    background:
      radial-gradient(circle at 50% 35%, rgba(61, 214, 198, 0.14), transparent 36%),
      rgba(6, 8, 10, 0.97);
    color: var(--ink);
    text-align: center;
    pointer-events: auto;
  }

  html.fl-touch .fl-orientation-icon {
    display: grid;
    place-items: center;
    width: 72px;
    height: 72px;
    border: 2px solid var(--gold);
    border-radius: 18px;
    color: var(--gold);
    font-size: 2.6rem;
    line-height: 1;
    transform: rotate(-35deg);
  }

  html.fl-touch .fl-orientation-title {
    font-family: var(--display);
    font-size: clamp(1.6rem, 7vw, 2.3rem);
    letter-spacing: 0.06em;
    text-transform: uppercase;
  }

  html.fl-touch .fl-orientation-text {
    max-width: 300px;
    color: var(--ink-2);
    font-family: var(--ui);
    font-size: 14px;
    line-height: 1.5;
  }

  html.fl-touch .fl-orient-fs-btn {
    margin-top: 8px;
    min-height: 52px;
    min-width: min(280px, 80vw);
    padding: 0 22px;
    pointer-events: auto;
    touch-action: manipulation;
    -webkit-tap-highlight-color: transparent;
    font-family: var(--display);
    font-size: 18px;
    letter-spacing: 0.08em;
  }

  html.fl-touch .fl-orientation-status {
    min-height: 1.2em;
    max-width: 300px;
    color: var(--gold);
    font-family: var(--ui);
    font-size: 13px;
    line-height: 1.4;
  }

  /* Hide interactive chrome under the rotate prompt so portrait taps do nothing useful. */
  html.fl-touch #menu,
  html.fl-touch #lobby,
  html.fl-touch #options,
  html.fl-touch #classes,
  html.fl-touch #pause,
  html.fl-touch #hud,
  html.fl-touch #game-canvas {
    visibility: hidden !important;
  }
}

html.fl-touch-playing .fl-mobile-hud {
  display: block;
  position: fixed;
  inset: 0;
  z-index: calc(var(--z-hud) + 2);
  pointer-events: none;
  touch-action: none;
  user-select: none;
  -webkit-user-select: none;
}

html.fl-touch-playing .fl-thumb-zone {
  position: absolute;
  bottom: max(14px, env(safe-area-inset-bottom));
  width: min(42vw, 240px);
  min-height: 220px;
  height: min(48vh, 220px);
  border-radius: 28px;
  border: 1px solid rgba(238, 241, 243, 0.12);
  background:
    radial-gradient(circle at 50% 58%, rgba(61, 214, 198, 0.12) 0 14%, transparent 15%),
    radial-gradient(circle at 50% 58%, rgba(255, 255, 255, 0.04), transparent 62%);
  pointer-events: auto;
  touch-action: none;
}

html.fl-touch-playing .fl-thumb-left {
  left: max(8px, env(safe-area-inset-left));
}

html.fl-touch-playing .fl-thumb-right {
  right: max(118px, calc(env(safe-area-inset-right) + 108px));
  width: min(48vw, 280px);
  min-height: 220px;
  height: min(56vh, 240px);
}

html.fl-touch-playing.fl-mobile-left .fl-thumb-left {
  left: auto;
  right: max(118px, calc(env(safe-area-inset-right) + 108px));
  width: min(48vw, 280px);
  min-height: 220px;
  height: min(56vh, 240px);
}

html.fl-touch-playing.fl-mobile-left .fl-thumb-right {
  right: auto;
  left: max(8px, env(safe-area-inset-left));
  width: min(42vw, 240px);
  min-height: 220px;
  height: min(48vh, 220px);
}

html.fl-touch-playing .fl-thumb-knob {
  position: absolute;
  left: calc(50% - 32px);
  top: calc(58% - 32px);
  width: 64px;
  height: 64px;
  border: 2px solid rgba(61, 214, 198, 0.7);
  border-radius: 50%;
  background: rgba(11, 14, 16, 0.78);
  box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4), inset 0 0 0 8px rgba(61, 214, 198, 0.08);
  pointer-events: none;
}

html.fl-touch-playing .fl-thumb-knob-look {
  border-color: rgba(125, 176, 255, 0.65);
  box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4), inset 0 0 0 7px rgba(125, 176, 255, 0.08);
}

html.fl-touch-playing .fl-mobile-actions {
  position: absolute;
  right: max(8px, env(safe-area-inset-right));
  bottom: max(14px, env(safe-area-inset-bottom));
  display: grid;
  grid-template-columns: repeat(2, minmax(52px, 68px));
  grid-template-areas:
    "swap crouch"
    "fire fire"
    "ads  jump"
    "reload sprint";
  gap: 8px;
  pointer-events: auto;
  z-index: 2;
}

html.fl-touch-playing.fl-mobile-left .fl-mobile-actions {
  right: auto;
  left: max(8px, env(safe-area-inset-left));
}

html.fl-touch-playing .fl-mobile-btn {
  min-height: 48px;
  padding: 0 8px;
  border: 1px solid rgba(238, 241, 243, 0.16);
  border-radius: 14px;
  background: rgba(11, 14, 16, 0.78);
  color: var(--ink);
  font-family: var(--display);
  font-size: 14px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  pointer-events: auto;
  touch-action: none;
  -webkit-tap-highlight-color: transparent;
}

html.fl-touch-playing .fl-mobile-btn-fire {
  grid-area: fire;
  min-height: 64px;
  background: rgba(228, 89, 108, 0.28);
  border-color: rgba(228, 89, 108, 0.55);
  font-size: 18px;
}

html.fl-touch-playing .fl-mobile-btn[data-action="ads"] { grid-area: ads; }
html.fl-touch-playing .fl-mobile-btn[data-action="jump"] { grid-area: jump; }
html.fl-touch-playing .fl-mobile-btn[data-action="reload"] { grid-area: reload; }
html.fl-touch-playing .fl-mobile-btn[data-action="sprint"] { grid-area: sprint; }
html.fl-touch-playing .fl-mobile-btn[data-action="swap"] { grid-area: swap; }
html.fl-touch-playing .fl-mobile-btn[data-action="crouch"] { grid-area: crouch; }

html.fl-touch-playing .fl-mobile-btn-sprint,
html.fl-touch-playing .fl-mobile-btn[data-action="sprint"] {
  min-height: 56px;
  padding: 0 10px;
}

html.fl-touch-playing .fl-mobile-btn:active,
html.fl-touch-playing .fl-mobile-btn:focus-visible {
  background: rgba(26, 33, 38, 0.92);
}

html.fl-touch-playing .fl-mobile-pause {
  position: absolute;
  top: max(8px, env(safe-area-inset-top));
  left: max(8px, env(safe-area-inset-left));
  z-index: 3;
  pointer-events: auto;
  touch-action: manipulation;
  min-height: 36px;
  padding: 0 14px;
  border: 1px solid rgba(238, 241, 243, 0.18);
  border-radius: 999px;
  background: rgba(11, 14, 16, 0.82);
  color: var(--ink);
  font-family: var(--display);
  font-size: 13px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

/* Compact HUD corners so sticks / fire cluster stay clear. */
html.fl-touch-playing #minimap {
  top: max(44px, calc(env(safe-area-inset-top) + 36px));
  left: max(10px, env(safe-area-inset-left));
  transform: scale(0.82);
  transform-origin: top left;
}
html.fl-touch-playing #weapon-block,
html.fl-touch-playing #streak {
  right: max(10px, env(safe-area-inset-right));
  bottom: auto;
  top: max(8px, env(safe-area-inset-top));
}
html.fl-touch-playing #score-header {
  top: max(6px, env(safe-area-inset-top));
  transform: translateX(-50%) scale(0.88);
  transform-origin: top center;
}
html.fl-touch-playing #feed-stack {
  left: max(10px, env(safe-area-inset-left));
  bottom: max(210px, calc(env(safe-area-inset-bottom) + 190px));
  max-width: 42vw;
}

/* Landscape phone menus: denser columns, less vertical waste, bigger taps.
 *
 * THE STACK BUG: at <=1040px `.menu-grid` collapses to one column with the
 * deployment panel (`.menu-setup`) stacked BELOW the nav. On an 844×390
 * touch phone both that breakpoint and this one match, so PRACTICE put nine
 * controls under the fold and the report was "all menu stuff is below screen
 * and is missed". The panel is meant to sit BESIDE the identity/actions;
 * two columns is the design above 1040 and it must also be the design here,
 * which is narrower but far shorter. Restored here for `html.fl-touch` only,
 * and only when PRACTICE is armed — when it is not, `#menu:not([data-practice])`
 * is correctly single-column and there is nothing to sit beside.
 *
 * WHY `padding-bottom` SHRINKS TO 72px HERE: the 124px at <=1040 is clearance
 * for the sticky `.sheet-foot` bar when the page scrolls. In this 2-col restoration
 * the menu no longer scrolls past the panel — the panel scrolls internally —
 * so the extra clearance just pushes content off a 390px viewport.
 *
 * `.menu-setup` is INTERNALLY SCROLLABLE and capped at ~68dvh rather than
 * `none`: `max-height:none` made the panel grow past the viewport so the whole
 * `.screen` had to scroll to reach DEPLOY, which on a short window means a
 * drag that starts outside the panel wins and the bottom fields never move. */
@media (orientation: landscape) and (max-height: 500px) {
  html.fl-touch .menu-grid {
    align-content: start;
    padding-top: max(10px, env(safe-area-inset-top));
    padding-bottom: max(12px, env(safe-area-inset-bottom));
    gap: 12px clamp(14px, 2vw, 20px);
  }
  /* PRACTICE armed: bring the rail back to the right. Overrides the
   * <=1040 single-column rule which otherwise wins on an 844px phone. */
  html.fl-touch #menu[data-practice="1"] .menu-grid {
    grid-template-columns: minmax(260px, 0.92fr) minmax(280px, 0.68fr);
    grid-template-areas: 'brand setup' 'nav setup';
    align-items: start;
    gap: 12px clamp(14px, 2vw, 20px);
  }
  html.fl-touch #menu[data-practice="1"] .brand,
  html.fl-touch #menu[data-practice="1"] .menu-main {
    max-width: none;
  }
  html.fl-touch .brand-mark .bm-front, html.fl-touch .brand-mark .bm-line { font-size: clamp(32px, 8vh, 56px); }
  html.fl-touch .tagline { display: none; }
  html.fl-touch .menu-main .btn {
    min-height: 44px;
  }
  /* The short landscape has 390-500px of height. The operational descriptors
   * yield there; native action labels and 44px targets remain intact. */
  html.fl-touch .menu-action-meta { display: none; }
  html.fl-touch .menu-setup {
    max-height: min(68dvh, 520px);
    overflow-y: auto;
    -webkit-overflow-scrolling: touch;
    overscroll-behavior: contain;
  }
  /* Keep the 2-col field grid even on the small panel so MAP/MODE/BOTS
   * do not each become a full-width row the user must scroll through. */
  html.fl-touch #menu[data-practice="1"] .menu-setup-grid {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
  html.fl-touch .controls-legend { display: none; }
  html.fl-touch #hud-hints { display: none; }
  html.fl-touch #scoreboard { top: 0; }
  html.fl-touch .sheet-foot {
    font-size: 11px;
    padding-top: 8px;
    padding-bottom: max(8px, env(safe-area-inset-bottom));
  }
  html.fl-touch .lobby-sheet > .sheet-body {
    padding-bottom: max(88px, calc(env(safe-area-inset-bottom) + 72px));
  }
  html.fl-touch .sheet-foot .btn,
  html.fl-touch .menu-main .btn.primary {
    min-height: 48px;
  }
}

@media (orientation: landscape) and (max-width: 920px) {
  html.fl-touch .menu-setup-grid {
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: 10px 14px;
  }
  html.fl-touch .opt-grid {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
  html.fl-touch #pause .sheet {
    max-width: min(520px, 92vw);
  }
}

/* Touch landscape: weapon-select / arsenal must not be clipped.
 *
 * `#classes[data-overlay="spawn"]` is intentionally bounded because it is a
 * translucent overlay over the loading veil and the spawn countdown. Every
 * OTHER appearance of `#classes` is a full-screen sheet with a sticky foot, and
 * the compact rule was leaking into the check that said "weapon select at games
 * is cut off" — measured on 844×390 the compact sheet gave 163px of list chrome.
 * So the compact rule stays scoped to `data-overlay="spawn"` (it already is),
 * and the FULL sheet gets explicit landscape-short overrides: a shorter gap and
 * a sheet that does not assume 720px of height, plus a 2-col arsenal grid so
 * the weapon list does not become a single 600px scroller under a 390px viewport.
 *
 * `.ars-groups` already collapses to 1-col at 720px; on this viewport it must
 * stay 2-col or the list is twice as tall as the screen while half the width is
 * empty. The foot's `96px` clearance in the <=820 lobby branch is correct for a
 * scrolled screen; here the sheet scrolls and the foot stays sticky, so the body
 * needs the same clearance without forcing a second scroller. */
@media (orientation: landscape) and (max-height: 500px) {
  html.fl-touch #classes:not([data-overlay]) .sheet,
  html.fl-touch #options .sheet {
    gap: 12px;
    padding-top: 12px;
    padding-bottom: 12px;
  }
  html.fl-touch #classes:not([data-overlay]) .sheet-body {
    padding-bottom: max(72px, calc(env(safe-area-inset-bottom) + 56px));
  }
  html.fl-touch #classes:not([data-overlay]) .ars-groups {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
  html.fl-touch #classes:not([data-overlay]) .ars-preview {
    position: static;
  }
  /* gsel: ensure the list can escape a short viewport even when the trigger is
   * near the bottom of a scrolled sheet. JS caps at 62vh; this lifts the CSS
   * fallback to the same number so a late-placed panel is not clipped by the
   * fallback before JS writes its inline max-height. */
  html.fl-touch .gsel-list {
    max-height: 62vh;
  }
}

html.fl-touch,
html.fl-touch body {
  /* Prefer the visual viewport while chrome collapses after fullscreen gesture. */
  min-height: 100dvh;
  min-height: -webkit-fill-available;
}
html.fl-touch.fl-touch-playing,
html.fl-touch:fullscreen,
html.fl-touch:-webkit-full-screen {
  background: #06080a;
}

/* Menu cue when touch shell is armed (before a match starts). */
html.fl-touch .fl-touch-menu-tip {
  display: block;
  margin: 0 0 12px;
  padding: 8px 12px;
  border: 1px solid rgba(61, 214, 198, 0.35);
  background: rgba(61, 214, 198, 0.1);
  color: var(--ink-2);
  font-family: var(--ui);
  font-size: 13px;
  line-height: 1.4;
}
.fl-touch-menu-tip { display: none; }

/* In-engine briefing map preview (poster + live backdrop pin). */
.briefing-live-map { position: relative; overflow: hidden; border-radius: 6px; }
.briefing-live-map .briefing-map-poster { display: block; width: 100%; height: auto; aspect-ratio: 16/9; object-fit: cover; }
.briefing-map-label {
  position: absolute; left: 10px; bottom: 8px;
  padding: 2px 8px; border-radius: 3px;
  background: rgba(0,0,0,0.55); color: #f2f2f2;
  font-size: 12px; letter-spacing: 0.04em; text-transform: uppercase;
}

/* ==========================================================================
   SPAWN COMPACT ARSENAL + FULL-SHEET PREVIEW
   ========================================================================== */
#classes[data-overlay="spawn"] #ars-cats,
#classes[data-overlay="spawn"] #btn-ars-restore,
#classes[data-overlay="spawn"] #ars-unlock-banner,
#classes[data-overlay="spawn"] #gunsmith,
#classes[data-overlay="spawn"] #ars-preview {
  display: none !important;
}
#classes[data-overlay="spawn"] .sheet {
  width: min(920px, 100%);
  /* The old 42vh cap was shorter than this sheet's title + gaps + sticky
   * action row on a 720px-tall laptop. The arsenal existed in the DOM but its
   * only weapon row landed underneath CONFIRM — DEPLOY, so the panel looked
   * empty. Give the shared picker an explicit body track and enough room for
   * two compact weapon rows; the screen's measured loading-band inset remains
   * the outer safety cap. */
  grid-template-rows: auto minmax(112px, 1fr) auto;
  height: min(68vh, 560px);
  max-height: calc(100% - 20px);
  overflow: hidden;
}
#classes[data-overlay="spawn"] .sheet-body {
  overflow-x: hidden;
  overflow-y: auto;
  padding-bottom: 4px;
}
#classes[data-overlay="spawn"] #arsenal {
  margin: 0;
}
#classes[data-overlay="spawn"] .ars-groups {
  display: flex;
  flex-wrap: wrap;
  gap: 8px 10px;
  max-height: none;
}
#classes[data-overlay="spawn"] .ars-group {
  display: contents;
}
#classes[data-overlay="spawn"] .ars-cat {
  display: none;
}
#classes[data-overlay="spawn"] .ars-item {
  min-width: 132px;
  flex: 0 0 auto;
  padding: 8px 10px;
  grid-template-columns: 1fr;
  gap: 2px;
}
.spawn-loadout-hint {
  color: var(--gold);
  letter-spacing: 0.02em;
  margin: 0;
}

/* Full sheet master-detail */
.ars-master {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(280px, 420px);
  gap: 12px 16px;
  align-items: start;
}
@media (max-width: 900px) {
  .ars-master { grid-template-columns: minmax(0, 1fr); }
  .ars-preview { order: -1; }
}
.ars-list-col { min-width: 0; }
.ars-groups {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 4px 10px;
}
@media (max-width: 720px) {
  .ars-groups { grid-template-columns: minmax(0, 1fr); }
}
.ars-group { display: contents; }
.ars-group > .ars-cat {
  grid-column: 1 / -1;
  margin: 6px 0 0;
}
.ars-item {
  display: grid;
  grid-template-columns: 1fr auto;
  align-items: center;
  gap: 4px 10px;
  text-align: left;
}
.ars-item .ars-name { grid-column: 1 / -1; }
.ars-item .ars-ttk { font-size: 11px; color: var(--ink-3); }
.ars-item.is-focus:not(.on) {
  outline: 1px solid var(--blue);
  outline-offset: 1px;
}
.ars-item.is-teaser {
  opacity: 0.72;
  border-style: dashed;
}
.ars-preview {
  position: sticky;
  top: 12px;
  display: flex;
  flex-direction: column;
  gap: 10px;
}
.ars-preview-stage {
  border: 1px solid var(--rule);
  border-radius: 8px;
  background: #8a9aaa;
  padding: 8px;
}
.ars-preview canvas {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 420 / 240;
  border-radius: 4px;
  background: radial-gradient(ellipse at 50% 42%, #c5d2de 0%, #8a9aaa 72%);
}
.ars-preview-meta {
  padding: 0 2px 2px;
}
.ars-preview-name {
  margin: 0 0 2px;
  font-family: var(--ui);
  font-size: 14px;
  font-weight: 700;
  letter-spacing: 0.04em;
  text-transform: uppercase;
}
.ars-preview-role {
  margin: 0 0 6px;
  font-size: 12px;
  color: var(--ink-2);
}
.ars-preview-stats {
  margin: 0;
  font-size: 12px;
  color: var(--ink-3);
}
.ars-preview-lock {
  margin: 8px 0 0;
  font-size: 12px;
  color: var(--gold);
}
#classes[data-overlay="spawn"] .ars-master {
  display: block;
}

/* --- Front Line visual owner (was frontline-theme.css) --- */
/* Front Line field-terminal identity.
 *
 * This sheet intentionally loads last. The older component owners still carry
 * their layout contracts; this is the single visual owner that makes those
 * contracts read as Front Line while they are replaced incrementally. Cyan is
 * UI signal only. Friendly/hostile data keeps the blue/red pair.
 */

/* Global tracking discipline: small labels must remain readable words. */
.panel-label, .field > span, .opt > span, .opt-group > h3, .sub,
.menu-action-meta, .sheet-lede, .gs-note, .kc-tag, .kc-victim,
.sb-head, .sb-team-role, .lobby-countdown-label, #spawnin-mode,
#spawnin-team, #spawnin-map, #firemode, #weapon-name, #hp-state,
#load-label, #load-pct, #load-steps, #load-tip, #boot-veil-note {
  letter-spacing: 0.06em !important;
}

.screen, #loading, #boot-veil { background-color: var(--s0); }
#host-setup.screen.modal,
#join-modal.screen.modal,
#options.screen.modal,
#fl-best-fs.screen.modal {
  background-color: rgba(4, 6, 8, 0.58);
}
.sheet, .modal-card, .campaign-plate, .lobby-rail, .join-browser {
  background: rgba(16, 20, 18, 0.97);
  border: 0;
  border-top: 1px solid color-mix(in srgb, var(--signal) 68%, transparent);
  border-radius: 0;
  clip-path: polygon(0 0, calc(100% - 10px) 0, 100% 10px, 100% 100%, 10px 100%, 0 calc(100% - 10px));
  backdrop-filter: none;
}

.btn {
  min-height: 42px;
  border-radius: 0;
  clip-path: polygon(0 0, calc(100% - 7px) 0, 100% 7px, 100% 100%, 0 100%);
  font-family: var(--ui);
  letter-spacing: 0.06em;
}
.btn.primary {
  background: var(--cta);
  color: var(--cta-ink);
  border-color: transparent;
  box-shadow: none;
}
.btn.primary:hover, .btn.primary:focus-visible {
  background: color-mix(in srgb, var(--cta) 88%, white);
  color: var(--cta-ink);
}
.btn.ghost { background: transparent; color: var(--ink-2); }
.btn:focus-visible { outline: 2px solid var(--signal); outline-offset: 2px; }

/* Main menu: one stamped identity plate, rail actions, compact instrument setup. */
.menu-grid {
  grid-template-columns: minmax(330px, 0.8fr) minmax(420px, 1.2fr);
  grid-template-areas: "brand setup" "nav setup";
  align-items: start;
  gap: clamp(22px, 3.2vw, 58px);
}
.brand { align-self: start; }
.brand-mark {
  display: inline-flex;
  align-items: center;
  gap: 0.18em;
  width: auto;
  padding: 12px 22px 10px 18px;
  color: var(--ink-display);
  background: rgba(10, 14, 12, 0.92);
  border-top: none;
  clip-path: polygon(0 0, calc(100% - 14px) 0, 100% 14px, 100% 100%, 0 100%);
  transform: none;
  line-height: 0.82;
  max-width: none;
  overflow: visible;
}
.brand .brand-mark .bm-front, .brand .brand-mark .bm-line {
  display: inline;
  font-family: var(--display);
  font-weight: 700;
  font-size: clamp(68px, 8.6vw, 148px);
  letter-spacing: 0.015em;
  color: inherit;
  animation: none !important;
}
.brand .brand-mark .bm-line { color: var(--signal); }
.menu-main {
  gap: 3px;
  padding-left: 14px;
  border-left: 1px solid var(--rule-strong);
}
.menu-main .btn.menu-action,
.menu-main .btn:not(.primary) {
  min-height: 56px;
  padding: 8px 14px 8px 12px;
  background: rgba(16, 20, 18, 0.84);
  border: 0;
  border-top: 1px solid transparent;
  text-align: left;
  transform: none;
  clip-path: polygon(0 0, calc(100% - 8px) 0, 100% 8px, 100% 100%, 0 100%);
}
.menu-main .btn.menu-action::before {
  content: '▸';
  display: inline-block;
  grid-area: caret;
  width: 18px;
  color: var(--ink-4);
}
.menu-main .btn.menu-action.primary {
  background: var(--cta);
  color: var(--cta-ink);
  border-top-color: transparent;
}
.menu-main .btn.menu-action:not(.primary):hover,
.menu-main .btn.menu-action:not(.primary):focus-visible {
  background: var(--s2);
  border-top-color: var(--signal);
}
.menu-main .menu-action-main { font-family: var(--display); font-size: 18px; font-weight: 600; letter-spacing: 0.045em; }
.menu-main .menu-action-meta { font-family: var(--mono); font-size: 11px; }
.menu-setup {
  align-self: end;
  max-height: min(72vh, 660px);
  padding: 18px;
  background: rgba(16, 20, 18, 0.96);
  border: 0;
  border-top: 1px solid var(--signal);
  clip-path: polygon(0 0, calc(100% - 10px) 0, 100% 10px, 100% 100%, 10px 100%, 0 calc(100% - 10px));
}
.menu-setup-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px 10px; }
.menu-setup .panel-label::before { content: '01 // '; color: var(--signal); font-family: var(--mono); }
.menu-setup .field select, .menu-setup .field input { min-height: 36px; background: var(--s2); }

/* Loading and boot: segmented terminal, no decorative radial wash. */
#loading {
  place-items: end start;
  background:
    linear-gradient(115deg, transparent 0 48%, rgba(230,235,232,.025) 48% 49%, transparent 49% 100%),
    repeating-radial-gradient(ellipse at 72% 35%, transparent 0 28px, rgba(230,235,232,.035) 29px 30px),
    var(--s0);
}
.load-inner { width: min(880px, 100%); padding-left: clamp(10px, 4vw, 52px); }
.load-top { border-left: 3px solid var(--signal); padding-left: 14px; }
.load-track, .boot-veil-track {
  height: 10px;
  background: repeating-linear-gradient(90deg, var(--s3) 0 52px, transparent 52px 57px);
  overflow: hidden;
}
#load-bar, .boot-veil-track i {
  background: repeating-linear-gradient(90deg, var(--signal) 0 52px, transparent 52px 57px);
}
#load-label::before { content: 'SYSTEM // '; color: var(--signal); font-family: var(--mono); }
#load-steps { font-family: var(--mono); }
.boot-veil-inner { justify-items: start; text-align: left; }
.boot-veil-mark {
  padding: 10px 18px;
  background: var(--s1);
  border-top: 2px solid var(--warn);
  clip-path: polygon(0 0, calc(100% - 12px) 0, 100% 12px, 100% 100%, 0 100%);
  transform: none;
  filter: none;
}
.boot-veil-mark .bm-front, .boot-veil-mark .bm-line { font-family: var(--display); font-weight: 700; }
.boot-veil-mark .bm-line { color: var(--signal); text-shadow: none; }

/* Options, pause, loadout and lobby: opaque field-manual geometry. */
.modal-card { box-shadow: 0 28px 80px rgba(0,0,0,.7); }
#pause { background: rgba(3, 5, 4, 0.76); backdrop-filter: none; }
#pause .modal-card { min-width: min(520px, 92vw); }
.controls-legend { font-family: var(--mono); columns: 2; column-gap: 24px; text-align: left; }
.opt-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px 28px; }
.opt-group { counter-increment: option-section; }
.opt-grid { counter-reset: option-section; }
.opt-group > h3::before, .opt-group > .panel-label::before {
  content: counter(option-section, decimal-leading-zero) ' // ';
  color: var(--signal);
  font-family: var(--mono);
}
input[type='range'] { accent-color: var(--signal); }
.ars-item, .gs-camo-btn, .comp-row, .join-lobby-row {
  border-radius: 0;
  background: transparent;
  border: 0;
  border-bottom: 1px solid var(--rule);
}
.ars-item.selected, .gs-camo-btn.selected, .comp-row.selected {
  background: var(--s2);
  box-shadow: inset 3px 0 0 var(--signal);
}
.ars-preview, .gs-preview {
  clip-path: polygon(14px 0, 100% 0, 100% calc(100% - 14px), calc(100% - 14px) 100%, 0 100%, 0 14px);
  background: var(--s0);
  box-shadow: inset 0 0 0 1px var(--rule-strong);
}
.lobby-sheet { background: var(--s0); }
.lobby-rail { border-top-color: var(--rule-strong); box-shadow: none; }
#lobby-preset-banner { border-color: var(--warn); color: var(--warn); background: color-mix(in srgb, var(--warn) 9%, transparent); }
.lobby-vis-tag { color: var(--cta-ink); background: var(--signal); border-color: transparent; }
.join-lobby-row:hover { border-color: var(--rule-strong); background: color-mix(in srgb, var(--signal) 10%, transparent); }
.lobby-countdown { background: rgba(5, 8, 6, .88); }
.lobby-countdown-card { background: none; box-shadow: none; }
.lobby-countdown-num {
  /* One number owns the dial. `--cd-ring` is the diameter; the digit is 0.485 of it,
   * which is the 64/132 the ring was drawn to. The state rules above move the
   * diameter and everything else follows, so the two can no longer disagree. */
  --cd-ring: 132px;
  display: grid;
  place-items: center;
  width: var(--cd-ring);
  height: var(--cd-ring);
  margin-inline: auto;
  border-radius: 50%;
  font-family: var(--num);
  font-size: calc(var(--cd-ring) * 0.485);
  background: radial-gradient(circle, var(--s1) 0 57%, transparent 58%), conic-gradient(var(--signal) 0 82%, var(--s3) 82% 100%);
}
.lobby-countdown-label::before { content: 'T-MINUS // '; color: var(--signal); }

/* HUD: instrument geometry, team colours remain semantically isolated. */
#score-header {
  min-width: min(620px, 62vw);
  padding: 7px 14px 9px;
  background: rgba(10, 14, 12, .9);
  border: 0;
  border-bottom: 2px solid var(--rule-strong);
  border-radius: 0;
  clip-path: polygon(8px 0, calc(100% - 8px) 0, 100% 8px, calc(100% - 8px) 100%, 8px 100%, 0 calc(100% - 8px), 0 8px);
  backdrop-filter: none;
}
#score-header .sh-side, #score-header .sh-mid { font-family: var(--num); }
#score-header .sh-bar { height: 2px; box-shadow: none; }
#tags-chip, #hill-chip { font-family: var(--mono); border-radius: 0; background: var(--s1); }
/* THE VISUAL OWNER USED TO CHAMFER THE RADAR SQUARE. It cannot any more, and the reason
 * is the same one that made the base rule round: the map turns inside this frame now, and
 * a chamfered rectangle reaches a different distance through each of its corners. The
 * theme keeps what it is actually for — the near-black plate and the thin cold ring — and
 * gives up the shape, which had become load-bearing somewhere else.
 *
 * `background` is left to the base rule on purpose: it is a radial gradient there, and a
 * flat fill here would put a visible disc edge inside a round bezel. */
#minimap {
  border: 1px solid rgba(230, 235, 232, .16);
  box-shadow: inset 0 0 0 1px rgba(150, 190, 220, 0.22), inset 0 0 15px rgba(3, 6, 8, 0.6);
}
#minimap-rings i { border-width: 1px; }
#crosshair i { height: 1px; opacity: .9; }
#hitmarker i { width: 7px; height: 2px; border-radius: 0; }
#hitmarker i:nth-child(1) { transform: rotate(24deg) translate(10px, 0); }
#hitmarker i:nth-child(2) { transform: rotate(-24deg) translate(10px, 0); }
#hitmarker i:nth-child(3) { transform: rotate(156deg) translate(10px, 0); }
#hitmarker i:nth-child(4) { transform: rotate(-156deg) translate(10px, 0); }
#blood { filter: hue-rotate(-8deg) saturate(.82); transform: scale(.85); }
.damage-dir { clip-path: polygon(50% 0, 100% 100%, 64% 76%, 50% 38%, 36% 76%, 0 100%); }

#feed-stack { font-family: var(--mono); }
.kf-row {
  position: relative;
  background: transparent;
  border: 0;
  border-left: 2px solid var(--ink-4);
  border-radius: 0;
  box-shadow: none;
  backdrop-filter: none;
}
.kf-row.friendly { border-left-color: var(--blue); }
.kf-row.hostile { border-left-color: var(--red); }
.kf-row.mine { background: rgba(230,235,232,.07); color: var(--ink); border-left-color: var(--ink); }
#banner {
  left: 6vw;
  right: auto;
  top: 28%;
  transform: none;
  padding: 5px 14px;
  background: var(--ink-display);
  color: var(--s0);
  clip-path: polygon(0 0, calc(100% - 9px) 0, 100% 9px, 100% 100%, 0 100%);
  font-family: var(--display);
  font-weight: 700;
  letter-spacing: .04em;
}
#banner-sub { left: 6vw; right: auto; transform: none; font-family: var(--mono); }
#medal-stack .medal { border-radius: 0; filter: none; }

#weapon-block {
  padding: 10px 12px;
  background: rgba(10, 14, 12, .88);
  border: 0;
  clip-path: polygon(9px 0, 100% 0, 100% 100%, 0 100%, 0 9px);
}
#weapon-name {
  display: inline-block;
  padding: 3px 10px;
  background: var(--s2);
  transform: skewX(-6deg);
  font-family: var(--display);
}
#ammo { font-family: var(--num); }
#ammo .mag { font-size: clamp(32px, 3.4vw, 46px); font-weight: 600; }
#ammo .reserve { font-size: 12px; }
.ammo-track {
  height: 7px;
  background: repeating-linear-gradient(90deg, var(--s4) 0 15px, transparent 15px 19px);
}
#ammo-fill { background: repeating-linear-gradient(90deg, var(--ink) 0 15px, transparent 15px 19px); }
#firemode { display: inline-block; padding: 2px 5px; border: 1px solid var(--ink-3); font-family: var(--mono); }
#hp-readout { background: none; border: 0; box-shadow: none; }
.hp-track {
  height: 8px;
  background: repeating-linear-gradient(90deg, var(--s4) 0 calc(20% - 3px), transparent calc(20% - 3px) 20%);
}
#hp-fill { background: repeating-linear-gradient(90deg, var(--ink) 0 calc(20% - 3px), transparent calc(20% - 3px) 20%); }
#hp-readout.is-critical #hp-fill { background: repeating-linear-gradient(90deg, var(--warn) 0 calc(20% - 3px), transparent calc(20% - 3px) 20%); }
#hp-value, #hp-state { font-family: var(--num); }

/* Spawn, replay and results: hard plates instead of radial/glass ceremony. */
#spawnin::before { background: rgba(3, 5, 4, .58); }
#spawnin .spawnin-inner, #spawnin > div {
  background: rgba(10, 14, 12, .92);
  clip-path: polygon(30px 0, 100% 0, calc(100% - 30px) 100%, 0 100%);
  padding: 22px 56px;
}
#spawnin-mode { display: inline-block; padding: 3px 10px; background: var(--signal); color: var(--cta-ink); }
#spawnin-count { font-family: var(--num); font-weight: 600; text-shadow: none; }
#spawnin-map { color: var(--ink); font-family: var(--display); font-size: clamp(32px, 5vw, 58px); }
/* THE FLAT 50% BLACK WASH IS GONE, AND IT WAS THE ACTUAL ANSWER TO "you can't see anything".
 *
 * `#killcam::before` is the frame element, and this line gave it a full-screen background
 * at half opacity — every pixel of the replay, halved, on top of the inset vignette on the
 * base rule. Measured A/B on one pinned camera, chrome the only variable: mean luma 99.2
 * with the killcam off and 49.0 with it on, and 124.2 vs 62.8 across the middle of the
 * frame where a player is actually looking. Exactly half, which is what `.5` means.
 *
 * I lightened the vignette on the base rule first and the A/B still came back at half,
 * which is the only reason this line was found: the fix that "should have worked" not
 * working is evidence, and it was worth measuring rather than shipping on the strength of
 * a plausible edit. The theme's job here is the strip and the tag below; the frame's
 * weight belongs to the base rule, which now carries a red border instead of a blackout. */
.kc-top { background: rgba(10,14,12,.94); border-top: 2px solid var(--signal); backdrop-filter: none; }
.kc-tag { background: var(--signal); color: var(--cta-ink); padding: 3px 7px; }
.kcf-rule, .kcf-text { color: var(--signal); border-color: var(--signal); }
#match-ended { background: rgba(3,5,4,.66); }
#match-ended .me-plate { background: none; box-shadow: none; }
#match-ended .me-tag { font-family: var(--mono); }

#scoreboard {
  top: 108px;
  right: 0;
  bottom: 0;
  left: auto;
  width: min(460px, 94vw);
  max-width: none;
  padding: 22px;
  background: rgba(10, 14, 12, .98);
  backdrop-filter: none;
  box-shadow: -20px 0 60px rgba(0,0,0,.5);
  border: 0;
}
#scoreboard.is-controls { width: min(420px, 94vw); }
#scoreboard .sb-head, #scoreboard .sb-row { grid-template-columns: minmax(0, 1fr) repeat(4, 42px); }
.sb-row, .sb-head { font-family: var(--num); }
.sb-row.me { background: var(--s2); box-shadow: inset 3px 0 0 var(--signal); }
.sb-row.me .sb-name, .sb-row.me .sb-rank { color: var(--ink); }
#endboard {
  width: min(1120px, 94vw);
  max-width: none;
  background: rgba(10,14,12,.98);
  border-top: 2px solid var(--signal);
  backdrop-filter: none;
}
.eb-actions .btn.primary { min-width: 220px; }

#toast, #net-notice, #warn-notice {
  left: auto;
  right: 22px;
  top: 22px;
  bottom: auto;
  transform: none;
  max-width: min(420px, calc(100vw - 44px));
  padding: 10px 14px;
  background: rgba(16,20,18,.97);
  border: 0;
  border-top: 1px solid var(--rule-strong);
  border-radius: 0;
  font-family: var(--mono);
  box-shadow: 0 10px 28px rgba(0,0,0,.4);
}
#warn-notice { border-top-color: var(--warn); color: var(--warn); top: 72px; }
#net-notice { color: var(--blue); top: 122px; }

/* Voice rows use one semantic speaking pip and no glass slab. */
#voice-hud .voice-row, .voice-row {
  background: transparent;
  border: 0;
  border-left: 2px solid var(--ink-4);
  border-radius: 0;
  font-family: var(--mono);
  box-shadow: none;
}
#voice-hud .voice-row.speaking, .voice-row.speaking { border-left-color: var(--signal); }

@keyframes flWipe {
  from { opacity: 0; clip-path: inset(0 100% 0 0); transform: translateX(-8px); }
  to { opacity: 1; clip-path: inset(0); transform: none; }
}
@keyframes flJitter {
  0%, 97%, 100% { transform: none; }
  98% { transform: translateX(-1px); }
  99% { transform: translateX(1px); }
}
.menu-main .btn { animation: flWipe 220ms var(--ease) 1 both !important; }
.brand-mark { animation: flJitter 11s steps(1) 2s infinite !important; }

@media (max-width: 980px) {
  .menu-grid { grid-template-columns: minmax(0, 1fr); grid-template-areas: 'brand' 'nav' 'setup'; }
  .menu-setup { width: min(760px, 100%); }
  #score-header { min-width: min(560px, 78vw); }
}

@media (max-width: 720px) {
  .menu-grid { display: flex; flex-direction: column; align-items: stretch; overflow-y: auto; }
  .brand .brand-mark .bm-front, .brand .brand-mark .bm-line { font-size: clamp(52px, 14vw, 88px); }
  .opt-grid { grid-template-columns: 1fr; }
  #score-header { min-width: 82vw; }
  #minimap { width: 116px; }        /* the dial is square; height follows aspect-ratio */
}

@media (prefers-reduced-motion: reduce) {
  .menu-main .btn, .brand-mark { animation: none !important; }
}

/* BLACKOUT FLIGHT RECORDER TOAST.
 *
 * The recorder is armed and saved with function keys and reports its state here, because
 * an instrument whose only feedback is the devtools console is unusable by the person who
 * actually has the bug. `--z-modal` rather than `--z-fx`: this must be readable over a
 * frame that has just gone black, which is the whole situation it reports on.
 * `pointer-events: none` keeps it out of aim, and it never renders during a killcam or
 * endboard because it is opacity-driven and clears itself. */
#blackout-notice {
  position: fixed;
  top: 14px;
  left: 50%;
  transform: translateX(-50%);
  z-index: var(--z-modal, 40);
  pointer-events: none;
  padding: 8px 16px;
  border-radius: 6px;
  background: rgba(8, 10, 12, 0.88);
  border: 1px solid rgba(255, 214, 120, 0.55);
  color: #ffd978;
  font: 600 15px/1.2 Arial, sans-serif;
  letter-spacing: 0.02em;
  opacity: 0;
  transition: opacity 180ms ease-out;
}
#blackout-notice[data-on="1"] { opacity: 1; }
