/* ══════════════════════════════════════════════════════════════════════════
   MEMPOOL INVADERS — shell styles.

   Token vocabulary is deliberately the SAME as mempool-survivors/web/style.css
   (--bg0 / --amber / --cyan / --ink / --dim / --panel / --panel-line / --font /
   --display / --status-live …) so the two games read as one product and anyone
   who has worked on survivors can work on this without relearning names. The
   Invaders-specific additions are --orange, --lime and --copper, spelled out
   because the build contract names them.

   THE LIME RULE, carried over from survivors' own comment ("lime — LIVE/online
   status only"): #D7FF2F is NEVER decorative. In this build it appears in
   exactly three places — the chain pill in its .live state, a tape row that has
   actually landed ON CHAIN, and a VERIFIED verdict. Anything else that wants to
   look important uses amber.
   ══════════════════════════════════════════════════════════════════════════ */

/* ══ THE SLASHED-ZERO FIX ══════════════════════════════════════════════════
   ORBITRON'S "0" IS A SQUARE WITH A BAR THROUGH IT. At 21-30px — SCORE, the
   first number any player ever sees — it does not read as a zero, it reads as a
   missing-glyph box, and a run that has not scored yet looks broken. It is the
   face's DEFAULT glyph, not an OpenType alternate, so no font-feature-settings
   can turn it off (measured: "zero" 0 renders pixel-identical). The only lever
   left is not to take the zero from Orbitron.

   So: one @font-face, no download, restricted to U+0030 alone, listed FIRST in
   --display. Every other glyph is still Orbitron; only the zero comes from the
   platform's own bold sans, which everywhere has a plain oval. Scoped to
   700-900 so ordinary-weight display text is untouched.
   ══════════════════════════════════════════════════════════════════════════ */
@font-face {
  font-family: 'Zero Sans';
  src: local('Segoe UI Bold'), local('Segoe UI'),
       local('Helvetica Neue Bold'), local('Helvetica Neue'),
       local('Roboto Bold'), local('Roboto'),
       local('Arial Bold'), local('Arial'),
       local('DejaVu Sans Bold'), local('DejaVu Sans'),
       local('Liberation Sans Bold'), local('Liberation Sans');
  unicode-range: U+0030;
  font-weight: 700 900;
  font-display: swap;
}

:root {
  /* ── Invaders brand contract ───────────────────────────────────────────── */
  --orange: #FF8A1A;        /* CTA / energy / the rack itself */
  --lime:   #D7FF2F;        /* CONFIRMED TRUTH ONLY — see the lime rule above */
  --copper: #FF5A14;        /* the cover accent — headings, edges, the wordmark */

  /* ── shared TX Blaster tokens (same names as survivors) ────────────────── */
  --bg0: #05070B;           /* cool neutral near-black. NEVER a warm brown. */
  --bg1: #0B1118;           /* panel base */
  --bg2: #101620;           /* raised panel */
  --cyan: #00CFEA;          /* the defender / system colour */
  --blue: #008CFF;
  --amber: #FF8A1A;
  --red:  #FF3B2E;
  --lila: #6A5CFF;
  --status-live: #D7FF2F;
  --ink:  #C7D1DB;
  --dim:  #6B7280;
  --mid:  #9CA3AF;
  --white: #E8EDF2;
  --panel: rgba(11, 17, 24, 0.88);
  --panel-line: #1e2636;
  --panel-hot: #2d3a50;
  --font:    'JetBrains Mono', ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace;
  /* 'Zero Sans' FIRST — see the @font-face above; it carries exactly one glyph.
     The tail is a sans rather than `monospace` for the same reason: Orbitron and
     JetBrains Mono arrive in ONE Google Fonts request, and when that request
     fails the generic monospace on most platforms ALSO has a slashed zero. */
  --display: 'Zero Sans', 'Orbitron', 'JetBrains Mono', 'Segoe UI', system-ui, sans-serif;
  --font-mono: var(--font);
  --font-hud:  var(--display);
  --glow-amber: 0 0 12px rgba(255, 138, 26, 0.45);
  --glow-lime:  0 0 12px rgba(215, 255, 47, 0.40);
  --hud-corner: 8px;
  --clip-corner: polygon(var(--hud-corner) 0%, 100% 0%, 100% calc(100% - var(--hud-corner)), calc(100% - var(--hud-corner)) 100%, 0% 100%, 0% var(--hud-corner));

  /* layout knobs */
  --gut-l: 188px;           /* HUD gutter */
  --gut-r: 292px;           /* THE TAPE gutter */
  /* THE PLAYFIELD HEIGHT BUDGET. Not the stage's height — the *most* height the
     stage is allowed to take. The shell script reads it off .stage-probe and
     quantises a scale into it (see "THE PLAYFIELD"). Sized so the common tall
     desktop lands on a whole 3x (3 * 256 = 768) instead of stopping at 2x. */
  --deck-h: min(76vh, 776px);
  --shell-pad: 14px;        /* the ONE horizontal page inset, referenced by the full-bleed stage */
}

* { box-sizing: border-box; margin: 0; padding: 0; }
[hidden] { display: none !important; }

html { background: var(--bg0); }
body {
  min-height: 100%;
  background:
    radial-gradient(120% 90% at 50% -10%, rgba(255, 90, 20, 0.09), transparent 58%),
    var(--bg0);
  color: var(--ink);
  font-family: var(--font);
  /* The house base, from chronicle style.css:72 — 13px / 1.4, not 14 / 1.5.
     Small on its own; together with the tagline dropping from 15px it is most of
     why the copy under the playfield used to read like a blog post sitting under
     an arcade cabinet. */
  font-size: 13px;
  line-height: 1.4;
  -webkit-text-size-adjust: 100%;
  -webkit-tap-highlight-color: transparent;
  overflow-x: hidden;          /* the page body must NEVER scroll sideways */
}

a { color: var(--cyan); text-decoration: none; }
a:hover { color: var(--orange); }
b, strong { color: var(--white); font-weight: 700; }
em { color: var(--orange); font-style: normal; }
kbd {
  display: inline-block; padding: 1px 5px; margin: 0 1px;
  border: 1px solid var(--panel-hot); border-bottom-width: 2px; border-radius: 3px;
  background: var(--bg2); color: var(--white); font-size: 11px; font-family: var(--font);
}

.skiplink {
  position: absolute; left: -9999px; top: 0; z-index: 999;
  display: inline-flex; align-items: center; min-height: 44px;
  padding: 8px 14px; background: var(--orange); color: #05070B; font-weight: 700;
}
.skiplink:focus { left: 8px; top: 8px; }

/* Focus is load-bearing on a keyboard game — never remove it, only style it. */
:focus-visible { outline: 2px solid var(--cyan); outline-offset: 2px; }

.mono { font-family: var(--font); font-variant-ligatures: none; letter-spacing: 0.02em; }
.num  { font-family: var(--display); font-weight: 800; letter-spacing: 0.04em; color: var(--white); }
/* SLASHED ZERO OFF, belt and braces. The 'Zero Sans' @font-face at the top of
   this file is what actually fixes the empty-box "0"; this line additionally
   turns the slash off in any face that exposes it as an OpenType feature rather
   than baking it into the default glyph. A slashed zero earns its keep in dense
   hex, which lives in .mono — never on the one number read at a glance. */
.num-lg {
  font-size: 30px; line-height: 1.05;
  font-variant-numeric: lining-nums;
  font-feature-settings: "zero" 0, "ss01" 0;
}

.shell {
  max-width: 1280px;
  margin: 0 auto;
  /* iOS SAFE AREA. The page declares viewport-fit=cover, so on a notched phone
     the physical edges of the viewport are under the notch, the rounded corners
     and the home-indicator strip. Every one of those is paid for here, once, in
     the single element that owns the page inset — which is also why the stage
     can cancel it with one negative margin (see .stage-wrap). */
  padding-left: max(var(--shell-pad), env(safe-area-inset-left));
  padding-right: max(var(--shell-pad), env(safe-area-inset-right));
  padding-bottom: calc(56px + env(safe-area-inset-bottom));
  /* Kills the double-tap-to-zoom delay on every control WITHOUT taking pinch
     zoom away — that is the whole reason maximum-scale/user-scalable are gone
     from the viewport tag. A low-vision player can still pinch the playfield. */
  touch-action: manipulation;
}

/* ══ TOP BAR ═══════════════════════════════════════════════════════════════ */
.topbar {
  display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
  /* viewport-fit=cover puts the top of the viewport UNDER the notch. */
  padding: calc(12px + env(safe-area-inset-top)) 2px 10px;
  border-bottom: 1px solid var(--panel-line);
}
/* ── THE HOUSE BACK-TO-SHELF MARK ──────────────────────────────────────────
   Copied, to the value, from mempool-survivors' .brand-home (web/style.css:200)
   — which is the same object breaker puts in its header. It is a three-part
   signature, not a word: CYAN chevron, AMBER "TX", BLASTER in --ink at Orbitron
   800 / 0.18em / 15px.

   What was here before was 11px, 0.22em, --dim, no chevron, no amber: the
   lowest-contrast text in the top bar, and the one element whose whole job is to
   say "this page is part of the arcade". */
.brand-home {
  display: inline-flex; align-items: center; white-space: nowrap;
  font-family: var(--display); font-size: 15px; font-weight: 800;
  letter-spacing: 0.18em; color: var(--ink);
}
.brand-home .chev { color: var(--cyan); font-weight: 800; margin-right: 2px; }
.brand-home .hx   { color: var(--amber); }
.brand-home:hover { color: var(--white); }
.brand-home:hover .chev { text-shadow: 0 0 10px rgba(0, 207, 234, 0.6); }

.wordmark {
  flex: 1 1 auto;
  display: flex; align-items: baseline; gap: 8px;
  font-family: var(--display); font-weight: 900; line-height: 1;
  text-transform: uppercase;
}
.wordmark .wm-1 {
  font-size: clamp(11px, 1.6vw, 14px); letter-spacing: 0.34em; color: var(--mid);
}
.wordmark .wm-2 {
  font-size: clamp(19px, 3.4vw, 30px); letter-spacing: 0.16em; color: var(--white);
  text-shadow: 0 0 14px rgba(255, 90, 20, 0.55), 0 0 2px rgba(255, 138, 26, 0.8);
}

.topbar-right { display: flex; align-items: center; gap: 8px; }

/* ── THE CHAIN STATUS PILL — four states, nothing else ─────────────────────
   THE RESTING STATE IS GREY. This pill is the whole off-chain treatment now, so
   the one thing it must not do is shout. .off used to be painted in --orange —
   the page's ACTION colour, the same fill as START RUN — which made the normal,
   expected, free-to-play state the loudest object in the top bar and said the
   same alarming thing the banner underneath was already saying.

   The house rule is breaker's (.pill rests in --fg-dim and only colours on a
   real event) and survivors' (DEMO amber, OFFLINE dim, LIVE lime + halo). The
   STEP UP is what carries the meaning: grey outline → lime text, lime border and
   a 12px lime halo the instant a run is genuinely on the tape. Nothing else on
   this page is lime, so that transition cannot be misread. */
.pill {
  display: inline-block; padding: 4px 10px; border-radius: 999px;
  border: 1px solid currentColor;
  font-family: var(--display); font-size: 10px; font-weight: 800;
  letter-spacing: 0.16em; white-space: nowrap;
}
.pill.live  { color: var(--lime);   box-shadow: var(--glow-lime); }
.pill.stale { color: #FFC24A; }
.pill.dark  { color: #FFC24A; opacity: 0.75; }
.pill.off   { color: var(--dim); border-color: var(--panel-hot); }
@keyframes pillbeat { 0%, 100% { opacity: 1; } 50% { opacity: 0.55; } }
.pill.stale, .pill.dark { animation: pillbeat 1.6s ease-in-out infinite; }

/* ── THE WALLET CHIP ───────────────────────────────────────────────────────
   The house chip, in the one place on this page where a fixed floating button
   is not allowed to go (see TXBW_NO_FAB in index.html: on a phone the canonical
   fab lands on PAUSE). Survivors' .wallet-chip, minus the balance line — the
   cyan-tinted panel fill, the cyan hairline, the amber BACK UP flag.

   The flag is the point. Twelve words hold real sats; an unbacked wallet is the
   one thing on this page worth interrupting for, and it is the ONLY reason this
   chip is allowed to carry a colour at all. */
.wallet-chip {
  appearance: none; cursor: pointer;
  display: inline-flex; align-items: center; gap: 6px;
  padding: 4px 9px; border-radius: 999px;
  background: linear-gradient(180deg, rgba(0, 207, 234, 0.08), rgba(11, 17, 24, 0.55));
  border: 1px solid rgba(0, 207, 234, 0.35);
  color: var(--ink);
  font-family: var(--font); font-size: 10px; font-weight: 700; letter-spacing: 0.12em;
  white-space: nowrap;
}
.wallet-chip:hover { border-color: var(--cyan); color: var(--white); }
.wallet-chip .wc-ic { font-size: 12px; line-height: 1; }
.wallet-chip .wc-flag {
  padding: 1px 5px; border-radius: 3px;
  background: var(--amber); color: #05070B;
  font-size: 9px; font-weight: 800; letter-spacing: 0.08em;
}
.wallet-chip.needs-backup { border-color: rgba(255, 138, 26, 0.55); }

/* ── language toggle ───────────────────────────────────────────────────── */
.langbar { display: flex; border: 1px solid var(--panel-line); border-radius: 5px; overflow: hidden; }
.lang-btn {
  appearance: none; border: 0; background: transparent; cursor: pointer;
  display: inline-flex; align-items: center; justify-content: center;
  padding: 5px 9px; font-family: var(--font); font-size: 11px; font-weight: 700;
  letter-spacing: 0.1em; color: var(--dim);
}
.lang-btn + .lang-btn { border-left: 1px solid var(--panel-line); }
.lang-btn:hover { color: var(--ink); }
.lang-btn.active { background: rgba(255, 138, 26, 0.14); color: var(--orange); }

/* ══ THE OFF-CHAIN STATUS — a status, not an alarm ════════════════════════
   Same element, same id, same two strings, same reveal condition (hud.js
   unhides it only when nothing is being broadcast). What changed is everything
   that made a working game look broken:

     · the 4px coloured LEFT BAR — gone. A thick coloured left rule plus a tinted
       fill plus bold coloured caps is the universal error-callout idiom, and the
       one full-width bar the house does ship (the dashboard's .queue-banner) has
       none of the three: a 1px box, a dim body, and colour on the two-word lead
       only.
     · the ORANGE TINT FILL and the ORANGE HEADLINE — gone. Orange is this page's
       ACTION colour; START RUN is orange. A notice drawn in the CTA colour reads
       as something you must deal with.
     · the position — it is no longer the second element on the page. It sits
       inside THE TAPE, directly under "every broadcast, as it happens" and one
       line above "not open yet", which is the panel the sentence is actually
       about.

   What did NOT change: the words, or the fact that the off state says out loud
   that nothing was broadcast, that there is no txid and that there is no SEAL.
   Quiet is not the same as hidden.

   FOUR STATES, ONE LINE. hud.js's paintChainNote() owns the copy and stamps
   .st-live / .st-stale / .st-dark / .st-off plus data-chain here; this file
   only decides how loud each one is. The answer is: barely, and equally. The
   state is carried by the WORDS (four different sentences) and by the pill in
   the top bar (grey → lime with a halo), which is the object the house uses for
   exactly this. Only the two states that mean "a broadcast is in trouble" take
   the pill's own amber on their lead, because that is the pair a player may
   want to act on.

   NO LIME HERE, deliberately, and it is not squeamishness: 'live' in this build
   means the tip feed is healthy and the session is running under the on-chain
   rule — it is NOT a confirmation that any particular transaction landed. The
   lime is reserved for things that did land: the pill, an accepted tape row, a
   VERIFIED verdict. A lime sentence next to a shot that has not been accepted
   yet would be the first overclaim on the page. */
#offchain-banner {
  display: flex; flex-direction: column; gap: 3px;
  padding: 7px 9px;
  border: 1px solid var(--panel-line);
  background: rgba(255, 255, 255, 0.018);
}
#offchain-banner .ob-lead,
#offchain-banner b {
  font-family: var(--font); font-size: 9.5px; font-weight: 700;
  letter-spacing: 0.14em; color: var(--mid);
}
#offchain-banner.st-stale .ob-lead,
#offchain-banner.st-stale b,
#offchain-banner.st-dark .ob-lead,
#offchain-banner.st-dark b { color: #FFC24A; }
#offchain-banner .ob-note { font-size: 11px; line-height: 1.45; color: var(--dim); }

/* ══ DECK ═════════════════════════════════════════════════════════════════ */
.deck {
  display: grid;
  grid-template-columns: var(--gut-l) minmax(0, 1fr) var(--gut-r);
  gap: 14px;
  align-items: start;
  padding: 14px 0;
}

/* ══ THE HOUSE PANEL ══════════════════════════════════════════════════════
   The dashboard's .card, and the reason its panels, breaker's stat tiles and
   survivors' console read as one product: a notched corner, a 1px border, and a
   CYAN RAIL fading in and out across the top edge, inset by the notch. The rail
   was the missing half — this build kept the clip-path notch and dropped the
   rail, and then set border-radius: 8px on the same elements, which rounds the
   three corners the notch is not on and quietly fights it.

   `position: relative` is load-bearing for the ::before. Both panels get it. */
.gutter, #tape-panel {
  position: relative;
  background: var(--panel);
  border: 1px solid var(--panel-line);
  border-radius: 0;                 /* the NOTCH is the corner treatment */
  clip-path: var(--clip-corner);
}
.gutter::before, #tape-panel::before {
  content: '';
  position: absolute; top: 0; left: var(--hud-corner); right: var(--hud-corner);
  height: 2px; pointer-events: none;
  background: linear-gradient(90deg, transparent, var(--cyan), transparent);
  opacity: 0.3;
  transition: opacity 0.3s;
}
.gutter:hover::before, #tape-panel:hover::before { opacity: 0.6; }

.gutter {
  display: flex; flex-direction: column; gap: 10px;
  padding: 12px 12px 14px;
}

.hstat { display: flex; flex-direction: column; gap: 2px; }
.hstat b {
  font-family: var(--font); font-size: 10px; font-weight: 700;
  letter-spacing: 0.16em; color: var(--dim);
}
.hstat-row { display: flex; gap: 14px; }
.hstat-row .hstat { flex: 1 1 0; }
.hstat-score .num-lg { color: var(--white); text-shadow: 0 0 14px rgba(255, 138, 26, 0.35); }

/* life pips */
.pips { display: flex; gap: 5px; margin-top: 3px; }
.pips .pip {
  width: 15px; height: 9px; border-radius: 2px;
  background: var(--cyan); box-shadow: 0 0 7px rgba(0, 207, 234, 0.55);
}
.pips .pip.spent { background: transparent; border: 1px solid var(--panel-hot); box-shadow: none; }
/* A FINALITY revive gets its own tint so a stream viewer can see it is in play. */
.pips .pip.revive { background: #FFD24D; box-shadow: 0 0 7px rgba(255, 210, 77, 0.6); }

/* the 15-tick UFO shot ring */
.ring { display: flex; gap: 2px; margin-top: 4px; }
.ring .tick {
  flex: 1 1 0; height: 10px; border-radius: 1px;
  background: rgba(255, 255, 255, 0.09);
  border-top: 1px solid transparent;
}
.ring .tick.on { background: var(--orange); box-shadow: 0 0 6px rgba(255, 138, 26, 0.6); }
/* index 8 = the only 300 in the table. Marked, not hidden: the mastery layer is
   meant to be discoverable, not folklore. */
.ring .tick-max { border-top-color: var(--copper); }
.ring .tick-max.on { background: var(--copper); box-shadow: 0 0 9px rgba(255, 90, 20, 0.85); }

/* Clean-wave streak. hud.setStreak() re-adds .flash on every change — both when
   it climbs and when a bunker breach resets it, because the reset is the half
   the player actually needs to feel. */
#hud-streak { color: var(--orange); }
@keyframes streakflash {
  0%   { transform: scale(1.35); color: var(--white); text-shadow: 0 0 18px var(--orange); }
  100% { transform: scale(1);    color: var(--orange); text-shadow: none; }
}
#hud-streak.flash { display: inline-block; transform-origin: left center; animation: streakflash 0.34s cubic-bezier(.2, 1.5, .4, 1); }

/* MARGIN CALL leverage arc — hazard stripes, filled via --lev (0..1) */
.lev { display: flex; flex-direction: column; gap: 3px; }
.lev b { font-family: var(--font); font-size: 10px; letter-spacing: 0.16em; color: var(--dim); }
.lev-bar {
  display: block; height: 10px; border-radius: 2px; overflow: hidden;
  border: 1px solid var(--panel-hot); background: rgba(255, 255, 255, 0.05);
}
.lev-bar > s {
  display: block; height: 100%; width: calc(var(--lev, 0) * 100%);
  background-image: repeating-linear-gradient(135deg, var(--copper) 0 6px, #1a0d05 6px 12px);
  transition: width 0.18s linear;
}

/* ══ THE PLAYFIELD ════════════════════════════════════════════════════════
   THE FRAME HUGS THE PICTURE. There is no letterbox and there are no bars: the
   stage box IS the picture, to the pixel, and everything decorative (the line,
   the copper halo, the drop shadow) is drawn with box-shadow, which costs zero
   layout. A `border` here would be inside the box and would shave 2px off a
   scale that is supposed to be exact — that is precisely how the old frame
   ended up 41% empty with the canvas pinned to its top-left corner.

   .stage-wrap is the available box; .stage-probe measures it (width from the
   column, height from --deck-h) and the shell script in index.html turns that
   into --px. Nothing else may set the stage's size.
   ════════════════════════════════════════════════════════════════════════ */
.stage-wrap {
  position: relative;
  min-width: 0;
  display: flex; justify-content: center; align-items: flex-start;
}
/* The height budget, made measurable. Zero layout cost, invisible, never
   focusable — its only job is to hand JS the number CSS already knows. */
.stage-probe {
  position: absolute; left: 0; top: 0;
  width: 100%; height: var(--deck-h);
  visibility: hidden; pointer-events: none;
}

#stage {
  position: relative;
  display: block;
  margin: 0 auto;
  background: #02040A;
  border: 0;
  border-radius: 3px;
  overflow: hidden;
  /* FALLBACK ONLY — holds a correct 224:256 frame for the one frame before the
     shell script quantises a scale, and forever if scripting is off. Width-led
     with an aspect-ratio, so clamping the width can never distort the picture
     the way a fixed height plus max-width could. */
  width: min(100%, calc(var(--deck-h) * 224 / 256));
  aspect-ratio: 224 / 256;
  box-shadow:
    0 0 0 1px var(--panel-line),
    0 0 0 3px rgba(255, 90, 20, 0.10),
    0 18px 60px rgba(0, 0, 0, 0.65);
}
/* THE QUANTISED SCALE. index.html's stage-fit script sets
       stage.style.setProperty('--px', n)
   where n * devicePixelRatio is a WHOLE NUMBER — device pixels are the only
   place "integer pixel" means anything. At dpr 3 a 390px phone carries 1170
   device px, so 224 * 5 = 1120 fits and --px is 1.6667: a fractional CSS scale
   that is still pixel-exact on the glass. This selector is the existing
   contract and is kept verbatim. */
#stage[style*="--px"] {
  width: calc(224px * var(--px));
  height: calc(256px * var(--px));
  aspect-ratio: auto;
}

#stage > canvas {
  position: absolute; inset: 0;
  /* !important, deliberately: game.js's fitCanvas() also writes an inline
     width/height here from its own idea of the frame, and two authorities over
     one box is what produced the 143px of dead frame on the right. The stage is
     now exactly the picture, so the canvas is exactly the stage — and the
     inline value, whatever it says, cannot desynchronise them. */
  width: 100% !important; height: 100% !important;
  margin: auto;
  display: block;
  /* Every pixel of this game is authored at 224x256. Never let the browser
     resample it — the 2px rack step dissolves into mush. */
  image-rendering: pixelated;
  image-rendering: crisp-edges;
  /* PINCH-ZOOM STAYS — see the viewport tag in index.html, which deliberately
     ships no maximum-scale and no user-scalable=no. `none` here quietly undid
     that promise on the one region the promise was made about: the playfield.
     `pinch-zoom` still refuses single-finger pan (a steering drag must never
     scroll the page) and still kills double-tap zoom; it only stops confiscating
     the two-finger gesture. */
  touch-action: pinch-zoom;
}
#c  { z-index: 1; background: #02040A; }
#fx { z-index: 2; pointer-events: none; }

/* CRT overlay — A TOGGLE, never permanent. btn-crt flips `crt` on <html>.
   A permanent CRT filter is routinely misread by players as "the game is low
   resolution", which is exactly the bug survivors shipped and then fixed. */
#stage .crt {
  position: absolute; inset: 0; z-index: 3; pointer-events: none;
  opacity: 0; transition: opacity 0.16s linear;
  background:
    radial-gradient(120% 92% at 50% 50%, transparent 54%, rgba(0, 0, 0, 0.6) 100%),
    repeating-linear-gradient(0deg, rgba(0, 0, 0, 0.20) 0 1px, transparent 1px 3px);
  mix-blend-mode: multiply;
}
html.crt #stage .crt { opacity: 1; }

/* One-pixel march flash so a muted player keeps the metronome. game.js toggles
   `beat` on <html> for a frame on every rack step. */
html.beat #stage { background: #06090F; }

/* ── THE START CURTAIN ─────────────────────────────────────────────────────
   The playfield is the first thing on the page and it used to look like it was
   already playable while the only START button sat 2,700px below the fold. This
   is the fix, and it costs zero vertical space: the invitation lives ON the
   screen it is inviting you to. It clicks #btn-start — it is chrome, it knows
   nothing about runs — and the shell removes it after the first start.
   ────────────────────────────────────────────────────────────────────────── */
.stage-cta {
  position: absolute; inset: 0; z-index: 4;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 10px; padding: 12px;
  background: radial-gradient(120% 80% at 50% 50%, rgba(2, 4, 10, 0.82), rgba(2, 4, 10, 0.96));
  text-align: center;
}
.stage-start {
  appearance: none; cursor: pointer;
  padding: 12px 22px; border-radius: 6px;
  background: var(--orange); color: #05070B; border: 1px solid transparent;
  font-family: var(--display); font-size: clamp(12px, 3.4vw, 15px);
  font-weight: 900; letter-spacing: 0.16em;
  box-shadow: var(--glow-amber);
  animation: ctapulse 1.9s ease-in-out infinite;
}
.stage-start:hover { background: #ffa040; }
.stage-start:disabled {
  background: transparent; border-color: var(--panel-hot); color: var(--mid);
  box-shadow: none; cursor: progress; animation: none;
}
.stage-cta-note {
  font-size: 11px; letter-spacing: 0.06em; color: var(--mid); max-width: 30ch; line-height: 1.8;
}
.stage-cta-note kbd { font-size: 10px; }
@keyframes ctapulse {
  0%, 100% { box-shadow: 0 0 12px rgba(255, 138, 26, 0.45); }
  50%      { box-shadow: 0 0 22px rgba(255, 138, 26, 0.85); }
}

/* ══ THE TAPE ═════════════════════════════════════════════════════════════ */
#tape-panel {
  display: flex; flex-direction: column; gap: 9px;
  padding: 13px 12px 12px;
}
/* The house card stamps: '// ' before the title, '[ ]' in the corner. Two
   characters each, and together with the rail they are what makes a box read as
   "a HUD module" rather than "a div with a border". */
.tape-head { position: relative; }
.tape-head h2 {
  font-family: var(--display); font-size: 11px; font-weight: 700;
  letter-spacing: 0.14em; color: var(--cyan);
  text-shadow: 0 0 8px rgba(0, 207, 234, 0.3);
}
.tape-head h2::before { content: '// '; color: var(--panel-hot); font-weight: 400; }
.tape-head::after {
  content: '[ ]';
  position: absolute; right: 0; top: -2px;
  font-family: var(--font); font-size: 8px; color: var(--panel-hot); opacity: 0.7;
}
.tape-head .tape-sub { display: block; font-size: 11px; color: var(--dim); }

#tape-root {
  display: block; font-size: 11px; color: var(--mid);
  padding: 6px 8px; border: 1px dashed var(--panel-hot); border-radius: 4px;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
#tape-root:hover { color: var(--orange); border-color: var(--orange); }

.tape-meters { display: flex; gap: 10px; }
.tape-meters .meter { flex: 1 1 0; min-width: 0; }
/* The ticker-only short labels. Off everywhere the full ones fit. */
.tape-meters .meter-short { display: none; }
.tape-meters b {
  display: block; font-size: 10px; letter-spacing: 0.14em; color: var(--dim);
}
#tape-spent { color: var(--white); font-size: 13px; }
#tape-left  { color: var(--white); font-size: 13px; }
#tape-left.low { color: var(--orange); }   /* under 40 transactions left */

.tape-flags { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
#tape-depth {
  font-size: 10px; letter-spacing: 0.12em; color: var(--dim);
  padding: 2px 7px; border: 1px solid var(--panel-line); border-radius: 3px;
}
#tape-depth.full { color: var(--orange); border-color: var(--orange); }
#tape-full {
  font-family: var(--display); font-size: 10px; font-weight: 800;
  letter-spacing: 0.14em; color: var(--copper);
  padding: 2px 7px; border: 1px solid var(--copper); border-radius: 3px;
  animation: tapefull 0.4s steps(2, jump-none);
}
@keyframes tapefull { from { opacity: 0.25; } to { opacity: 1; } }

/* the live txid feed */
#tape-feed {
  list-style: none;
  display: flex; flex-direction: column; gap: 3px;
  min-height: 132px; max-height: 168px;
  overflow-y: auto; overflow-x: auto;
  padding-right: 2px;
}
#tape-feed li {
  display: flex; align-items: baseline; gap: 7px;
  font-size: 11px; white-space: nowrap;
  padding: 2px 4px; border-left: 2px solid var(--panel-hot);
  color: var(--dim);
}
#tape-feed li .seq  { color: var(--dim); min-width: 34px; }
#tape-feed li .kind { font-weight: 700; letter-spacing: 0.06em; }
#tape-feed li .txid { color: inherit; }
#tape-feed li.queued  { border-left-color: var(--dim);    color: var(--dim); }
#tape-feed li.signed  { border-left-color: var(--orange); color: var(--orange); }
/* the ONE decorative-looking lime in the HUD, and it is not decorative: this row
   is a transaction a relay actually acknowledged. */
#tape-feed li.onchain { border-left-color: var(--lime);   color: var(--lime); }
/* A transaction the emitter permanently abandoned. Struck through and red so it
   can never be mistaken for one still on its way — the failure is part of the
   record, not something to quietly repaint as QUEUED. */
#tape-feed li.failed  { border-left-color: var(--red);    color: var(--red); }
#tape-feed li.failed .kind,
#tape-feed li.failed .txid { text-decoration: line-through; }
#tape-feed li.tape-empty { border-left-color: transparent; white-space: normal; color: var(--dim); }
@keyframes rowin { from { opacity: 0; transform: translateX(-6px); } to { opacity: 1; transform: none; } }
#tape-feed li { animation: rowin 0.16s ease-out; }

/* Deliberately NOT lime. This is a call to action — an invitation to go and
   check — not a verdict. Only the verdict itself earns the lime, and it is
   rendered on tape.html after a digest actually matched. */
#tape-verify {
  display: block; text-align: center;
  padding: 8px; border: 1px solid var(--orange); border-radius: 5px;
  font-family: var(--display); font-size: 11px; font-weight: 800; letter-spacing: 0.12em;
  color: var(--orange);
}
#tape-verify:hover { background: rgba(255, 138, 26, 0.10); color: var(--orange); }

/* ══ TOUCH PAD ════════════════════════════════════════════════════════════ */
.touchpad { display: none; gap: 10px; padding: 4px 0 10px; }
.tbtn {
  appearance: none; cursor: pointer; user-select: none;
  -webkit-user-select: none;
  /* PINCH-ZOOM STAYS. `none` here disabled the one gesture a low-vision player
     has left, on the very region they would pinch — the pad sits directly under
     the 224x256 picture. `pinch-zoom` keeps everything `none` was actually for
     (no pan, no double-tap zoom, no key-repeat storm from a dragged thumb) and
     gives the gesture back. The shell bridge's preventDefault() on pointerdown
     cannot cancel it: scrolling and zooming are decided by touch-action, not by
     the pointer event. */
  touch-action: pinch-zoom;
  flex: 1 1 0; min-height: 62px;
  background: var(--bg2); border: 1px solid var(--panel-hot); border-radius: 8px;
  color: var(--ink); font-family: var(--display); font-size: 18px; font-weight: 800;
  letter-spacing: 0.1em;
}
.tbtn-fire { flex: 1.6 1 0; color: var(--orange); border-color: var(--orange); font-size: 14px; }
.tbtn.down { background: rgba(255, 138, 26, 0.22); border-color: var(--orange); color: var(--white); }

/* ══ GFX / TRANSPORT CONTROLS ═════════════════════════════════════════════ */
.ctlbar { display: flex; flex-wrap: wrap; gap: 8px; padding: 2px 0 16px; }
.ctl {
  appearance: none; cursor: pointer;
  display: inline-flex; align-items: center; justify-content: center;
  min-height: 34px;
  padding: 6px 12px; border-radius: 5px;
  background: transparent; border: 1px solid var(--panel-line); color: var(--mid);
  font-family: var(--font); font-size: 11px; font-weight: 700; letter-spacing: 0.12em;
}
.ctl:hover { border-color: var(--panel-hot); color: var(--ink); }
.ctl[aria-pressed="true"] { border-color: var(--orange); color: var(--orange); }

/* ══ MENU ═════════════════════════════════════════════════════════════════ */
.menu { padding-top: 6px; }
/* Optional affordance: game.js may set <html data-inv-state="playing"> to fold
   the menu away while a run is live. Nothing breaks if it never does. */
html[data-inv-state="playing"] .menu,
html[data-inv-state="playing"] .guide { display: none; }

.eyebrow {
  font-family: var(--display); font-size: 11px; font-weight: 700;
  letter-spacing: 0.24em; color: var(--copper); text-transform: uppercase;
}
.tagline { max-width: 72ch; margin: 6px 0 20px; color: var(--ink); font-size: 13px; }

/* ══ THE HOUSE SECTION HEADER ═════════════════════════════════════════════
   Four devices, all four from the dashboard's .section-header (chronicle
   style.css:2158): a CYAN DIAMOND on the left, a CYAN Orbitron title, a dim mono
   subtitle on the same baseline, a hairline rule, and a 60px CYAN TICK under the
   rule's left edge.

   What was here was --mid — grey — with no diamond, no tick and no cyan, which
   is why this page's headings read as generic labels while the rest of the site
   reads as one system. §1 of the design system: cyan = system, amber = action.
   This page had evicted cyan almost entirely and was doing both jobs in orange,
   so a heading and a button were the same colour. */
.sechead {
  position: relative;
  display: flex; align-items: baseline; flex-wrap: wrap; gap: 4px 14px;
  margin: 26px 0 12px; padding-bottom: 9px;
  border-bottom: 1px solid var(--panel-line);
}
.sechead::after {
  content: ''; position: absolute; left: 0; bottom: -1px;
  width: 60px; height: 1px; background: var(--cyan);
}
.sec-title {
  position: relative;
  font-family: var(--display); font-size: 12px; font-weight: 800;
  letter-spacing: 0.18em; color: var(--cyan);
  text-shadow: 0 0 10px rgba(0, 207, 234, 0.28);
  margin: 26px 0 12px; padding: 0 0 9px 14px;
  border-bottom: 1px solid var(--panel-line);
}
.sec-title::before {
  content: ''; position: absolute; left: 0; top: 4px;
  width: 6px; height: 6px; transform: rotate(45deg);
  background: var(--cyan); box-shadow: 0 0 6px rgba(0, 207, 234, 0.5);
}
.sec-title::after {
  content: ''; position: absolute; left: 0; bottom: -1px;
  width: 60px; height: 1px; background: var(--cyan);
}
/* Inside a .sechead the wrapper owns the rule and the tick, so the title drops
   both and only keeps its diamond. */
.sechead .sec-title { margin: 0; padding-bottom: 0; border-bottom: 0; }
.sechead .sec-title::after { display: none; }
.sec-sub { font-family: var(--font); font-size: 11px; color: var(--dim); }

.hint { color: var(--dim); font-size: 12px; margin: 8px 0; max-width: 74ch; }
.hint-fund { margin-top: 10px; }

/* ══ DISCLOSURES ══════════════════════════════════════════════════════════
   The two blocks that used to make this the tallest game page on the site — the
   22-card hangar wall and the four-paragraph guide — are kept in full and put
   behind a <details>. A disclosure, not a deletion: every word and every card is
   one tap away, and the summary states what is inside so the tap is informed.

   The marker is drawn here rather than left to the user-agent triangle, which
   renders differently in every browser and in none of them looks like this
   site. It rotates on open. */
.fold { margin: 18px 0 0; }
.fold-sum {
  position: relative;
  display: flex; align-items: baseline; flex-wrap: wrap; gap: 4px 14px;
  cursor: pointer; list-style: none;
  padding: 9px 0 9px 22px;
  border-bottom: 1px solid var(--panel-line);
  font-family: var(--display); font-size: 11px; font-weight: 800;
  letter-spacing: 0.18em; color: var(--mid);
}
.fold-sum::-webkit-details-marker { display: none; }
.fold-sum::before {
  content: '';
  position: absolute; left: 4px; top: 50%;
  width: 0; height: 0; margin-top: -4px;
  border-left: 6px solid var(--cyan);
  border-top: 4px solid transparent;
  border-bottom: 4px solid transparent;
  transform-origin: 25% 50%;
  transition: transform 0.18s ease-out;
}
.fold[open] > .fold-sum::before { transform: rotate(90deg); }
.fold-sum:hover { color: var(--ink); }
/* A .sec-title inside a summary is already carried by the summary's own rule and
   marker — it keeps the cyan and drops the box furniture. */
.fold-sum .sec-title { margin: 0; padding: 0; border-bottom: 0; }
.fold-sum .sec-title::before, .fold-sum .sec-title::after { display: none; }
.fold > *:not(.fold-sum) { margin-top: 12px; }

/* ── mode picker ───────────────────────────────────────────────────────────
   TWO MARKUPS, ONE LOOK — and they must be styled TOGETHER.

   index.html ships `.mode` cards (name + tagline) as the pre-JS fallback, but
   the moment the engine arrives game.js's renderModePicker() throws them away
   and emits `class="mode-btn"` (+ ` active` on the selected one), one line of
   text, with the mode's own colour parked on `--acc`. `.mode-btn` and `.active`
   matched ZERO rules in this sheet, so the first control a player ever touches
   was four raw white OS buttons on a black page with nothing at all marking the
   selected one. Every rule below therefore names BOTH class names, and the
   engine's own class is left alone — it is not ours to rename.

   `--acc` defaults to the brand orange, so a mode that ships no colour of its
   own still lands on the palette instead of on the user-agent's grey. */
#mode-picker {
  display: grid; gap: 9px;
  grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
}
.mode, .mode-btn {
  --acc: var(--orange);
  position: relative;
  appearance: none; cursor: pointer; text-align: left;
  display: flex; flex-direction: column; justify-content: center; gap: 4px;
  /* 44px is the floor, not the height: the fallback card is taller because it
     carries a tagline. The engine's one-line button lands exactly on it. */
  min-height: 44px;
  padding: 12px 28px 12px 13px;      /* right pad clears the selection dot */
  background: var(--bg1);
  border: 1px solid var(--panel-line);
  border-left: 3px solid var(--panel-hot);
  border-radius: 7px;
  color: var(--white);
  /* The engine's button has no inner <b> to carry the display face — it sets
     textContent straight onto the button — so the face lives here. */
  font-family: var(--display); font-size: 13px; font-weight: 800; letter-spacing: 0.14em;
  transition: border-color 0.12s linear, background-color 0.12s linear;
}
/* BOTH MARKUPS, ALWAYS TOGETHER. renderModePicker() now emits the same
   <b> + <span> pair the static fallback ships, so these rules have to name
   `.mode-btn` as well or the engine's picker loses its type scale the instant
   it replaces the fallback — which is precisely the bug the block comment
   above this section describes, one level deeper. */
.mode b, .mode-btn b {
  display: flex; align-items: baseline; gap: 7px;
  font-family: var(--display); font-size: 13px; font-weight: 800; letter-spacing: 0.14em;
  color: var(--white);
}
.mode span, .mode-btn span {
  font-family: var(--font); font-size: 11px; font-weight: 400; letter-spacing: 0;
  color: var(--dim); line-height: 1.45;
}
/* The mode glyph (∞ ⚡ ▚ ☠) in the mode's own colour — the one part of the card
   that reads before any text does, and the only one that needs no translation. */
.mode-ic {
  font-style: normal; font-size: 13px; line-height: 1;
  color: var(--acc); flex: 0 0 auto;
}
.mode.active b, .mode-btn.active b { color: var(--acc); }
.mode.active span, .mode-btn.active span { color: var(--ink); }
.mode:hover, .mode-btn:hover {
  border-color: var(--panel-hot); border-left-color: var(--acc); background: var(--bg2);
}

/* THE SELECTED STATE. Four signals, because one is never enough on a dark page:
   the accent rail on the left, the tint, the text colour, and the dot. */
.mode.active, .mode-btn.active {
  border-color: var(--acc);
  border-left-color: var(--acc);
  color: var(--acc);
  background: var(--bg2);
  background: color-mix(in srgb, var(--acc) 12%, var(--bg1));
  box-shadow: 0 0 0 1px color-mix(in srgb, var(--acc) 35%, transparent);
}
.mode.active b { color: var(--acc); }

/* The dot: empty ring = selectable, filled + lit = selected. Language-neutral
   on purpose — it is the one part of the picker that must not need a string. */
.mode::after, .mode-btn::after {
  content: '';
  position: absolute; right: 11px; top: 50%; transform: translateY(-50%);
  width: 9px; height: 9px; border-radius: 50%;
  border: 1px solid var(--panel-hot); background: transparent;
}
.mode.active::after, .mode-btn.active::after {
  border-color: var(--acc); background: var(--acc);
  box-shadow: 0 0 9px color-mix(in srgb, var(--acc) 80%, transparent);
}

.mode .rules { display: flex; gap: 5px; flex-wrap: wrap; margin-top: 3px; }
.mode .rules i {
  font-style: normal; font-family: var(--font); font-weight: 400;
  font-size: 9px; letter-spacing: 0.1em;
  padding: 1px 5px; border: 1px solid var(--panel-hot); border-radius: 3px; color: var(--mid);
}

/* ── hangar ────────────────────────────────────────────────────────────────
   THE CLASS NAMES HERE ARE hangar.js's OWN. They used to be a parallel
   vocabulary (.hull / .hull-chip / .tier / .lock) that the markup never
   produced, so every card rendered as a raw unstyled <button> — which is also
   why the perk panel read as one run-on string. One vocabulary, one owner.
   ────────────────────────────────────────────────────────────────────────── */
/* THE GRID IS TIGHTER NOW BECAUSE THE CARD IS SHORTER. The old card stacked
   five strings and needed 132px of width to set a sentence; this one is a
   render with two short labels under it, so more of the fleet fits on one
   screen — which was the actual complaint. */
#hangar-grid {
  display: grid; gap: 8px;
  grid-template-columns: repeat(auto-fill, minmax(108px, 1fr));
  align-items: stretch;
}
#hangar-grid .hangar-note { grid-column: 1 / -1; color: var(--dim); font-size: 12px; }
/* Section rules — THE FLEET / SPECIAL SHIPS / THE TREECHAT SQUADRON. */
.hangar-sec {
  grid-column: 1 / -1;
  margin-top: 6px; padding-bottom: 4px;
  border-bottom: 1px solid var(--panel-line);
  font-family: var(--display); font-size: 10px; font-weight: 800;
  letter-spacing: 0.2em; color: var(--dim);
}
.hangar-sec:first-child { margin-top: 0; }

.hangar-card {
  appearance: none; cursor: pointer; text-align: center;
  display: flex; flex-direction: column; gap: 3px;
  padding: 7px 6px 8px; border-radius: 8px;
  background: var(--bg1); border: 1px solid var(--panel-line); color: var(--ink);
  transition: border-color 0.12s linear, background-color 0.12s linear, transform 0.12s ease;
}

/* ── THE ART WELL ─────────────────────────────────────────────────────────
   The <img> and the tint slab are STACKED, not alternatives in the markup:
   the slab is the floor and the render sits on top of it. When the webp is
   missing (or still loading) the card looks exactly like the old one; when it
   lands, `.has-img` hides the slab. That is the same graceful path survivors
   uses, and it means a 404 on models/ can never produce a broken-image glyph
   in the middle of the fleet. */
.hangar-art {
  position: relative; display: block; width: 100%;
  aspect-ratio: 1 / 1; border-radius: 6px; overflow: hidden;
  background: #0A0F16; border: 1px solid rgba(255, 255, 255, 0.05);
}
.hangar-img {
  position: absolute; inset: 0;
  width: 100%; height: 100%; object-fit: cover; display: block;
}
.hangar-hull {
  position: absolute; inset: 0;
  /* hangar.js paints --hull from the hull's own tint literal. Kept as the
     fallback floor AND as a faint wash over the render, which is what ties a
     squadron's five hulls together visually — they share one tint. */
  background: var(--hull, #EAF6FF); opacity: 0.85;
}
.hangar-card.has-img .hangar-hull {
  opacity: 0.14; mix-blend-mode: color;
}

/* THE LOCK BADGE. One word, over the art, in the state's own colour — the
   sentence that used to sit under the card is now the tooltip and the
   aria-label. Rule 1 of hangar.js is intact: a locked card still says why,
   it just no longer shouts it at sixteen cards' worth of volume. */
.hangar-badge {
  position: absolute; left: 3px; right: 3px; bottom: 3px;
  padding: 2px 3px; border-radius: 3px;
  background: rgba(5, 7, 11, 0.86);
  font-family: var(--font); font-size: 8px; font-weight: 700;
  letter-spacing: 0.08em; color: var(--dim);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}

.hangar-name {
  font-family: var(--display); font-size: 10px; font-weight: 800;
  letter-spacing: 0.06em; color: var(--white); line-height: 1.2;
}
/* TIER IS A COLOUR, NOT A SENTENCE. Same ladder survivors uses, so a player
   moving between the two games reads the same rarity at a glance. */
.hangar-tier {
  font-family: var(--font); font-size: 8px; font-weight: 700;
  letter-spacing: 0.14em; color: var(--dim); text-transform: uppercase;
}
.hangar-tier.tier-rare      { color: var(--cyan); }
.hangar-tier.tier-legendary { color: var(--orange); }
.hangar-tier.tier-champion  { color: #C77DFF; }
.hangar-tier.tier-stock     { color: var(--dim); }

.hangar-card:hover:not(:disabled) {
  border-color: var(--panel-hot); background: var(--bg2); transform: translateY(-1px);
}
.hangar-card:disabled { cursor: not-allowed; }
/* Locked hulls are DESATURATED, not merely dimmed — the silhouette stays fully
   readable (you can see what you would be flying) while never reading as
   available. Survivors' exact treatment. */
.hangar-card:disabled .hangar-img { filter: grayscale(1) brightness(0.5); }
.hangar-card:disabled .hangar-name { color: var(--dim); }

/* A champion staring at a grey prize with no explanation is a reputational
   event, not a bug report — every locked card states WHY, in its own colour. */
.hangar-card.locked      .hangar-badge { color: var(--orange); }
.hangar-card.unreachable .hangar-badge { color: #FFC24A; }
.hangar-card.checking    .hangar-badge { color: var(--mid); }
.hangar-card.nowallet    .hangar-badge { color: var(--mid); }
.hangar-card.preview     .hangar-badge { color: var(--cyan); }

.hangar-card.is-selected {
  border-color: var(--orange); background: rgba(255, 138, 26, 0.08);
  box-shadow: 0 0 0 1px var(--orange), 0 0 14px rgba(255, 138, 26, 0.28);
}
.hangar-card.is-selected .hangar-name { color: var(--orange); }
.hangar-card.is-selected .hangar-art { border-color: rgba(255, 138, 26, 0.5); }
.hangar-buy { font-size: 9px; letter-spacing: 0.1em; color: var(--cyan); }

#hangar-perk {
  margin-top: 12px; padding: 11px 13px;
  background: var(--bg1); border: 1px solid var(--panel-line); border-left: 3px solid var(--copper);
  border-radius: 6px;
}
#hangar-perk > b {
  display: block; font-family: var(--font); font-size: 10px;
  letter-spacing: 0.16em; color: var(--dim); margin-bottom: 4px;
}
#hangar-perk p { font-size: 12.5px; color: var(--ink); line-height: 1.55; }
/* The perk panel prints the HULL once (head) and the PERK once (name). They are
   two different facts and they never repeat each other. */
.hangar-perk-head { display: flex; align-items: center; gap: 9px; margin-bottom: 7px; }
/* The 14px tint swatch that used to live here as a ::before is now the hull's
   own render — same job (say which ship this panel is about, before the name),
   far more information per pixel. The tint survives as the well's background,
   so a missing webp still lands on the old look. */
.hangar-perk-art {
  position: relative; flex: 0 0 auto;
  width: 40px; height: 40px; border-radius: 5px; overflow: hidden;
  background: var(--hull, #EAF6FF);
  border: 1px solid var(--panel-line);
}
.hangar-perk-art .hangar-img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.hangar-perk-ship { font-family: var(--display); font-size: 11px; font-weight: 800; letter-spacing: 0.14em; color: var(--white); }
.hangar-perk-preview {
  font-size: 9px; letter-spacing: 0.14em; color: var(--cyan);
  padding: 1px 5px; border: 1px solid var(--cyan); border-radius: 3px;
}
.hangar-perk-name {
  font-family: var(--display); font-size: 12px; font-weight: 800;
  letter-spacing: 0.16em; color: var(--copper); margin-bottom: 4px;
}
.hangar-perk-body { font-size: 12.5px; color: var(--ink); line-height: 1.55; }
.hangar-perk-fine { margin-top: 7px; font-size: 11px; color: var(--dim); line-height: 1.45; }

/* ── start / fund ──────────────────────────────────────────────────────── */
.startbar { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 22px; }
.cta {
  appearance: none; cursor: pointer;
  padding: 13px 24px; border-radius: 6px;
  font-family: var(--display); font-size: 13px; font-weight: 800; letter-spacing: 0.14em;
  border: 1px solid transparent;
}
.cta-go { background: var(--orange); color: #05070B; box-shadow: var(--glow-amber); }
.cta-go:hover { background: #ffa040; }
.cta-fund { background: transparent; border-color: var(--cyan); color: var(--cyan); }
.cta-fund:hover { background: rgba(0, 207, 234, 0.10); }
.cta-ghost { background: transparent; border-color: var(--panel-hot); color: var(--mid); }
.cta-ghost:hover { border-color: var(--ink); color: var(--ink); }
.cta:disabled { opacity: 0.45; cursor: not-allowed; box-shadow: none; }
a.cta { display: inline-block; text-align: center; }

/* ── guide ─────────────────────────────────────────────────────────────── */
.guide ul { list-style: none; display: grid; gap: 9px; }
.guide li {
  padding: 10px 13px; border-left: 2px solid var(--panel-hot);
  background: rgba(11, 17, 24, 0.5); border-radius: 0 5px 5px 0;
  font-size: 13px; line-height: 1.6; color: var(--ink);
}

/* ── leaderboard ───────────────────────────────────────────────────────── */
#board {
  width: 100%; border-collapse: collapse; font-size: 12px;
  display: block; overflow-x: auto;      /* wide table scrolls in itself, not the page */
  white-space: nowrap;
}
#board th, #board td { padding: 7px 10px; text-align: left; border-bottom: 1px solid var(--panel-line); }
#board th { font-size: 10px; letter-spacing: 0.14em; color: var(--dim); font-weight: 700; }
#board td { color: var(--ink); }
#board td.score { font-family: var(--display); font-weight: 800; color: var(--white); }
#board tr.me td { background: rgba(255, 138, 26, 0.08); }
#board .board-empty td { color: var(--dim); }
/* VERIFIED is a chain fact, so it earns the lime. Unverified is just grey. */
#board td.verified { color: var(--lime); }
#board td.unverified { color: var(--dim); }

/* ── footer ────────────────────────────────────────────────────────────── */
.foot { margin-top: 34px; padding-top: 14px; border-top: 1px solid var(--panel-line); }
.footlinks { display: flex; flex-wrap: wrap; gap: 16px; margin-bottom: 8px; }
.footlinks a {
  font-family: var(--font); font-size: 11px; letter-spacing: 0.12em; color: var(--dim);
}
.footlinks a:hover { color: var(--orange); }
/* The dashboard's own two-item status strip (chronicle index.html:794): mono,
   dim, one divider. It is a signature, not a link farm. */
.footstamp {
  display: flex; align-items: center; gap: 10px; margin-top: 10px;
  font-size: 10px; letter-spacing: 0.14em; color: var(--panel-hot);
}
.footstamp .divider { color: var(--panel-line); }

/* ══ SCRIMS: pause + run end ══════════════════════════════════════════════ */
.scrim, #gameover {
  position: fixed; inset: 0; z-index: 80;
  display: flex; align-items: center; justify-content: center;
  /* A fixed overlay covers the notch and the home-indicator strip, so it pays
     for all four insets itself — the shell's padding is behind it. */
  padding-top: calc(18px + env(safe-area-inset-top));
  padding-bottom: calc(18px + env(safe-area-inset-bottom));
  padding-left: max(18px, env(safe-area-inset-left));
  padding-right: max(18px, env(safe-area-inset-right));
  background: rgba(2, 4, 10, 0.86);
  backdrop-filter: blur(3px);
  overflow-y: auto;
}
/* The pause scrim is `position: fixed; inset: 0` at z-index 80, so it painted
   straight over the control row — and the row is where #btn-pause lives. The
   button dutifully relabelled itself to RESUME and then could never be tapped:
   elementsFromPoint over it returned the scrim first. On a phone that is the
   only transport control there is, so the game could be paused and not
   resumed from the place the player just pressed.
   Lift the row above the PAUSE scrim only, and only while that scrim is up —
   never above #gameover, which owns its own actions and must stay modal. */
html:has(.scrim-pause:not([hidden])) .ctlbar { position: relative; z-index: 90; }

.panel {
  width: min(560px, 100%);
  padding: 20px 22px 22px;
  background: var(--bg1); border: 1px solid var(--panel-hot); border-radius: 9px;
  clip-path: var(--clip-corner);
  box-shadow: 0 24px 80px rgba(0, 0, 0, 0.7);
}
.panel h2 {
  font-family: var(--display); font-size: 20px; font-weight: 900;
  letter-spacing: 0.16em; color: var(--white); margin-bottom: 4px;
}
.panel-actions { display: flex; flex-wrap: wrap; gap: 9px; margin-top: 18px; }

#pause-ledger {
  width: 100%; border-collapse: collapse; font-size: 12px;
  display: block; overflow-x: auto;
}
#pause-ledger th, #pause-ledger td {
  padding: 5px 9px; text-align: left; border-bottom: 1px solid var(--panel-line);
}
#pause-ledger th { font-size: 10px; letter-spacing: 0.14em; color: var(--dim); }
#pause-ledger td:nth-child(1) { font-weight: 700; color: var(--white); letter-spacing: 0.06em; }
#pause-ledger td:nth-child(2),
#pause-ledger td:nth-child(3) { font-family: var(--font); color: var(--ink); }
#pause-ledger tr.total td { border-top: 1px solid var(--panel-hot); color: var(--orange); font-weight: 700; }
#pause-ledger .led-empty td { color: var(--dim); }
/* Neither of these is money. They sit NEXT to the confirmed count, never inside
   the sats column, so the ledger cannot be misread as a bill for work the relay
   has not acknowledged. */
#pause-ledger .led-pending { color: var(--dim);   font-weight: 400; font-size: 11px; }
#pause-ledger .led-failed  { color: var(--red);   font-weight: 400; font-size: 11px; }

.go-grid {
  display: grid; gap: 10px 18px;
  grid-template-columns: repeat(auto-fit, minmax(108px, 1fr));
  margin: 14px 0;
}
.go-grid > div { display: flex; flex-direction: column; gap: 1px; }
.go-grid b { font-size: 10px; letter-spacing: 0.14em; color: var(--dim); font-weight: 700; }
.go-grid span { font-family: var(--display); font-weight: 800; color: var(--white); font-size: 17px; }
.go-grid .num-lg { font-size: 28px; color: var(--orange); }
.go-grid .mono { font-family: var(--font); font-size: 13px; font-weight: 400; color: var(--ink); }

.go-sealrow { display: flex; flex-wrap: wrap; gap: 8px; align-items: baseline; font-size: 12px; }
.go-sealrow b { font-size: 10px; letter-spacing: 0.14em; color: var(--dim); }
.go-sealrow .go-seal { color: var(--mid); word-break: break-all; }
.go-state {
  margin-top: 8px; font-family: var(--display); font-size: 11px; font-weight: 800;
  letter-spacing: 0.14em; color: var(--dim);
}
/* VERIFIED — the third and last lime in the build. */
.go-state.verified { color: var(--lime); text-shadow: var(--glow-lime); }

/* ══ TOAST ════════════════════════════════════════════════════════════════ */
#toast {
  position: fixed; left: 50%; bottom: calc(22px + env(safe-area-inset-bottom)); transform: translateX(-50%);
  z-index: 90; max-width: min(460px, calc(100vw - 28px));
  padding: 9px 16px; border-radius: 6px;
  background: var(--bg2); border: 1px solid var(--panel-hot);
  color: var(--ink); font-size: 12.5px; text-align: center;
  box-shadow: 0 10px 34px rgba(0, 0, 0, 0.6);
  animation: toastin 0.18s ease-out;
}
/* A confirmation toast is cyan, not lime: "the thing you asked for happened" is
   a system message, not a chain fact. */
#toast.ok  { border-color: var(--cyan);   color: var(--cyan); }
#toast.err { border-color: var(--orange); color: var(--orange); }
@keyframes toastin { from { opacity: 0; transform: translate(-50%, 8px); } to { opacity: 1; transform: translate(-50%, 0); } }

/* ══ RESPONSIVE ═══════════════════════════════════════════════════════════
   The playfield is never squeezed. Below 1080px the tape narrows; below 760px
   it becomes a single-line ticker under the screen and the touch pad appears.
   ═════════════════════════════════════════════════════════════════════════ */
@media (max-width: 1080px) {
  :root { --gut-l: 158px; --gut-r: 236px; --deck-h: min(70vh, 700px); }
  #tape-feed { min-height: 104px; max-height: 132px; }
}

@media (max-width: 880px) {
  /* HUD folds to a strip above the screen; the tape keeps its own column
     until the ticker breakpoint. */
  .deck { grid-template-columns: minmax(0, 1fr); }
  .gutter-l {
    flex-direction: row; flex-wrap: wrap; align-items: flex-end; gap: 8px 18px;
  }
  .gutter-l .hstat, .gutter-l .hstat-row { flex: 0 0 auto; }
  .gutter-l .ring { width: 132px; }
  .lev { flex: 1 1 120px; }
  /* 64vh of a 1024-tall tablet is 655px, which is what lets a dpr-2 iPad
     quantise to a whole 5 device px per logical px (a 560x640 picture) instead
     of stalling one device pixel short at 4. Budgets are chosen to LAND on an
     integer, not rounded off for tidiness. */
  :root { --deck-h: min(64vh, 680px); }
}

/* A landscape phone has no room for chrome and no reason to keep any. The
   wordmark goes, the top bar and the HUD strip shrink, and what is left goes to
   the playfield — otherwise the height binds so hard that the device-pixel
   quantiser drops the picture to a stamp. */
@media (max-height: 560px) and (orientation: landscape) {
  :root { --deck-h: min(78vh, 520px); }
  /* THE `order` RULES BELOW ASSUME ONE COLUMN, so this line has to be here or
     they do the opposite of what they say. A short LANDSCAPE window can still
     be wide — a 956px iPhone Pro Max on its side, or a half-height desktop
     window — and above 880px the deck is a three-column grid. Reordering grid
     items there does not stack them, it re-auto-places them: the playfield
     landed in the 188px HUD column and drew itself 188x215. Measured on a
     1400x500 window before this line existed. */
  .deck { grid-template-columns: minmax(0, 1fr); }
  /* The screen goes FIRST. With 390px of height and the honesty banner up, the
     HUD strip above the stage was the difference between the playfield (and its
     START curtain) fitting in the viewport and being clipped by 39px. */
  .stage-wrap { order: 1; }
  .gutter-l { order: 2; }
  #tape-panel { order: 3; }
  .wordmark { display: none; }
  .topbar { padding-top: calc(5px + env(safe-area-inset-top)); padding-bottom: 5px; }
  .deck { padding: 6px 0; }
  .gutter-l { padding: 5px 10px; gap: 4px 14px; }
  .touchpad { padding: 3px 0 6px; }
  .tbtn { min-height: 46px; }
}

/* A LANDSCAPE PHONE IS A NARROW PAGE THAT HAPPENS TO BE WIDE. It was matching
   none of the phone rules below — 844 CSS px is not "max-width: 760px" — so
   THE TAPE stayed a full 292px-tall PANEL stacked under the picture on a 390px
   screen. That one element was pushing the touch pad and the control row nearly
   three hundred pixels further down than they had to be. The second query is
   qualified with `pointer: coarse` rather than a width, because the thing that
   makes it a phone is not how many CSS pixels it has — a landscape iPhone Pro
   Max is 956 of them — it is that you touch it. A short desktop window never
   matches and never grows a touch pad. */
@media (max-width: 760px),
       (max-height: 560px) and (orientation: landscape) and (pointer: coarse) {
  :root {
    --shell-pad: 10px;
    /* ── THE FOLD BUDGET, MEASURED INSTEAD OF GUESSED ────────────────────────
       `min(60vh, 560px)` is a share of the screen, and the thing that has to fit
       is not a share of the screen — it is everything that is NOT the picture:
       the top bar (two rows on a phone), the HUD strip, the tape ticker, the
       touch pad, the transport row, the gaps between them, and BOTH safe-area
       insets. Measured on this page that is 322px, and 344 is that plus the
       slack the measurements below were tuned to.

       On a 390x844 phone 60vh was never the binding term anyway (the picture is
       width-bound there) and nothing changes. On the shapes where it WAS wrong
       this is the whole fix — every number here measured on this page, before
       and after:

         375x667 (SE/8, no insets): the control row used to end at y=734 on a
           667px screen. PAUSE, the only pause a touch player has, was 67px past
           the bottom of the page and no amount of the shell's own padding could
           reach it. Now it ends at 617, with 50px to spare.
         390x844 with a real notch (47 top, 34 bottom): the row ended at 818
           with the home-indicator strip starting at 810 — the last 8px of every
           button sat inside the system's own swipe-up gesture area. Now 752,
           58px clear.
         414x896 with a notch: 862 against a strip starting at 862, i.e. correct
           by luck to the pixel. Now 838, 24px clear.
         360x740: 13px of clearance, and the radio fab landed on CRT. Now 38.

       env() inside calc() is why both insets can be priced here rather than
       being paid for twice further down the page. A device with no insets
       resolves them to 0. The constant is deliberately ONE number in ONE place:
       if the chrome above or below the picture ever changes height, this is the
       line to re-measure, and everything else follows. */
    --fold-chrome: 344px;
    --deck-h: min(
      60vh,
      560px,
      calc(100vh - var(--fold-chrome) - env(safe-area-inset-top) - env(safe-area-inset-bottom))
    );
  }
  .shell { padding-bottom: calc(44px + env(safe-area-inset-bottom)); }

  /* FULL-BLEED PLAYFIELD. The page inset is 10px a side; on a 390px phone that
     is the difference between 4 and 5 device pixels per logical pixel — a 299px
     picture and a 373px one. The stage, and only the stage, cancels it. */
  .stage-wrap {
    margin-left: calc(-1 * max(var(--shell-pad), env(safe-area-inset-left)));
    margin-right: calc(-1 * max(var(--shell-pad), env(safe-area-inset-right)));
  }

  /* THE TAPE collapses to a one-line ticker so the playfield is never squeezed.
     COLLAPSED, not merely narrowed: it used to be 723px of content in a 368px
     box, which meant DEPTH — the one number that explains a refused trigger —
     was permanently off-screen. The panel itself never scrolls now; only the
     feed does, because a log is the one thing that may. */
  #tape-panel {
    flex-direction: row; align-items: center; gap: 8px;
    overflow: hidden;
    padding: 5px 8px;
    -webkit-overflow-scrolling: touch;
  }
  #tape-panel > * { flex: 0 0 auto; }
  /* "THE TAPE / every broadcast, as it happens" is a title for a panel. There is
     no panel here — the row IS the tape. */
  .tape-head { display: none; }
  /* hud.js REMOVES the href until a run has a root, so this hides the "not open
     yet" placeholder and reveals the real txid the moment one exists. */
  #tape-root:not([href]) { display: none; }
  #tape-root { max-width: 30vw; padding: 3px 6px; font-size: 10px; }
  .tape-meters { gap: 8px; }
  .tape-meters b { display: none; }
  /* flex: 0 0 auto and nowrap, together: a shrinkable basis-0 meter wrapped
     "0 sats" onto two lines and made the ticker 49px tall instead of 30. */
  .tape-meters .meter { display: flex; align-items: baseline; gap: 4px; flex: 0 0 auto; white-space: nowrap; }
  /* The short labels only exist at this width — see i18n tape_spent_short /
     tape_left_short. "TXS RESTANTES" does not fit and never will. */
  .tape-meters .meter-short { display: inline; font-size: 9px; letter-spacing: 0.1em; color: var(--dim); }
  #tape-spent, #tape-left { font-size: 11px; }
  #tape-depth { font-size: 9px; padding: 1px 5px; letter-spacing: 0.06em; }
  #tape-feed {
    flex: 1 1 0; min-width: 0;
    flex-direction: row; align-items: center; gap: 8px;
    min-height: 0; max-height: none;
    overflow-x: auto; overflow-y: hidden;
  }
  #tape-feed li { border-left: 0; border-bottom: 2px solid var(--panel-hot); padding: 1px 3px; }
  #tape-feed li.queued  { border-bottom-color: var(--dim); }
  #tape-feed li.signed  { border-bottom-color: var(--orange); }
  #tape-feed li.onchain { border-bottom-color: var(--lime); }
  #tape-feed li.failed  { border-bottom-color: var(--red); }
  /* On a ticker only the freshest rows are readable anyway — keep the last 4. */
  #tape-feed li:nth-last-child(n + 5) { display: none; }
  /* The empty-state row wraps to three lines in a 150px-wide ticker and that
     alone made the whole bar 49px tall instead of 30. It scrolls with the rest
     of the feed instead. */
  #tape-feed li.tape-empty { white-space: nowrap; font-size: 10px; }
  #tape-verify { padding: 6px 12px; }

  /* ── THE FOLD BUDGET ────────────────────────────────────────────────────
     PAUSE IS THE ONLY PAUSE A TOUCH PLAYER HAS, and the control row carrying
     it measured bottom 861.7 on a 844px-tall phone — clipped off the page, and
     what was left of it sat inside the iOS home-indicator strip. Nothing below
     is decorative trimming: it is the 66px that puts the row back on the
     screen, taken from the padding that was spending it. The playfield itself
     pays nothing — --deck-h is untouched, and on a phone the picture is
     width-bound anyway. */
  /* THE CHAIN NOTE ON A TICKER. The tape stops being a panel here and becomes
     one horizontal row, so the note cannot sit inside it as a block — it takes
     the SECOND row of the same bar. The wrap is tied to the note being present
     with :has() (the same device the pause scrim's z-index rule uses two hundred
     lines below), so the one-row ticker that was tuned to exactly 30px comes
     straight back if the note is ever taken away.

     The body sentence is the half that does not fit a 390px phone; the lead is
     the load-bearing half and it stays, at full width, under the tape it is
     about — and it is a lead that changes with the state, so it never goes
     stale. The full sentence is one rotation or one desktop away, and the pill
     above it is on screen at all times. */
  #tape-panel:has(#offchain-banner:not([hidden])) { flex-wrap: wrap; }
  #offchain-banner {
    flex: 1 0 100%; order: 9;
    margin-top: 3px; padding: 3px 0 0;
    border: 0; border-top: 1px solid var(--panel-line); background: none;
  }
  #offchain-banner .ob-lead,
  #offchain-banner b { font-size: 9px; letter-spacing: 0.08em; line-height: 1.3; }
  #offchain-banner .ob-note { display: none; }
  #tape-panel { padding: 4px 8px; }
  .touchpad { display: flex; padding: 3px 0 5px; }

  /* ONE ROW, FOUR EQUAL CELLS. Left to flex-wrap this bar became two rows of
     28px-tall buttons on a 360px phone — 82px of height for controls nobody
     could hit. A 4-column grid is one row at every phone width, and every cell
     is a real 44px target. */
  .ctlbar {
    display: grid; grid-template-columns: repeat(4, minmax(0, 1fr));
    gap: 6px;
    padding: 4px 0 6px;
  }
  /* THE RADIO'S SLOT. The shared radio's fab is fixed to the bottom-left corner,
     which on a phone is this row — and a fixed 42px button over a transport
     control is the exact failure TXBW_NO_FAB was set for. Rather than fight it
     with z-index (tested: the row then punches through the radio's open panel
     and looks broken) the row simply starts 52px in, so the fab has a column of
     its own and NO control can ever share a pixel with it, at any scroll offset.
     :has() ties the hole to the widget: off the production path mount /radio.js
     404s, #txb-radio never exists, and the row keeps its full width. */
  body:has(#txb-radio) .ctlbar { padding-left: 52px; }
  .ctl {
    min-height: 44px; padding: 4px 3px;
    font-size: clamp(9px, 2.5vw, 11px); letter-spacing: 0.02em;
    white-space: nowrap; overflow: hidden;
  }
  /* Everything above the playfield is rent. The wordmark keeps its own line —
     it is the only branding on the page — but pays a smaller one, and the HUD
     strip is squeezed so that the screen and its START curtain land inside the
     first screenful on a 780-844px-tall phone. */
  .wordmark { width: 100%; order: 3; }
  .wordmark .wm-2 { font-size: clamp(17px, 5vw, 22px); }
  .topbar { gap: 6px 12px; padding-bottom: 6px; }
  .deck { padding: 6px 0; gap: 6px; }
  .gutter-l { padding: 7px 10px; gap: 5px 10px; align-items: center; }
  .gutter-l .hstat b, .gutter-l .lev b { font-size: 9px; letter-spacing: 0.12em; }
  /* SCORE keeps its rank. Everything else in the strip gives up size. */
  .gutter-l .num:not(.num-lg) { font-size: 13px; }
  .gutter-l .num-lg { font-size: 21px; }
  .gutter-l .ring { width: 96px; }
  .gutter-l .ring .tick { height: 8px; }
  .gutter-l .pips .pip { width: 12px; height: 8px; }
  .go-grid { grid-template-columns: repeat(auto-fit, minmax(92px, 1fr)); }
  .panel { padding: 16px; }
}

/* …and the ONE thing the phone block must not carry into landscape: its height
   budget. `min(60vh, 560px)` of a 390px-tall screen is 234px, which would have
   cost the landscape picture a quarter of its size in exchange for chrome it
   does not need. Declared after the block above so it wins. */
@media (max-height: 560px) and (orientation: landscape) and (pointer: coarse) {
  :root { --deck-h: min(78vh, 520px); }

}


/* ══ THE PHONE TOP BAR ════════════════════════════════════════════════════
   FOUR OBJECTS, 370 PIXELS, TWO ROWS — DECIDED, NOT LEFT TO WRAP. The house
   mark, the chain pill, the wallet chip and the language rail add up to about
   410px of content on a 390px phone, so flex-wrap chose the break itself and
   chose badly: the mark took a whole row on its own, the three chips took a
   second, the wordmark a third. Ninety-eight pixels of top bar on a screen
   where the transport row was fighting for fourteen.

   So the bar is a two-row grid with the break written down. `display: contents`
   on .topbar-right dissolves the wrapper that was holding its three children
   together, without touching the markup. Portrait only (620px) — a landscape
   phone is 667-956px wide and keeps its single row. */
@media (max-width: 620px) {
  .topbar {
    display: grid;
    grid-template-columns: auto minmax(0, 1fr) auto;
    /* THE BREAK, WRITTEN DOWN. Row one is the two NAMES — the arcade's and the
       game's, the pair that says where you are. Row two is the three STATES —
       the chain, the wallet and the language, the ones you read or act on.

       The split is arithmetic before it is editorial. The wordmark is 170px of
       Orbitron that cannot break, the wallet chip is another 79 with its BACK UP
       flag up, the pill 100 and the language rail 90: any arrangement that puts
       the wordmark next to two of the other three overflows a 390px phone.
       Measured both wrong ways first — the title clipped to "MEMPOOL INVAI" in
       one, the chip printed over the shelf mark in the other. */
    grid-template-areas:
      "brand pill lang"
      "word  word chip";
    align-items: center;
    gap: 6px 8px;
    padding-top: calc(9px + env(safe-area-inset-top));
  }
  .topbar-right { display: contents; }
  /* 13px here rather than the house 15: the mark is the widest fixed object in
     the bar and every pixel it gives up goes straight to the game's own name on
     the row below. The signature — cyan chevron, amber TX — is what carries it,
     not the point size. */
  .brand-home  { grid-area: brand; font-size: 13px; }

  /* The chip goes to its short form for the whole phone range, not just under
     480: the purse says WALLET and the flag says the only thing that needs
     doing. Both are still 44px of tap target — see the pseudo-element trick in
     the touch-targets block. */
  .wallet-chip .wc-t { display: none; }
  .wallet-chip { padding: 4px 6px; gap: 4px; overflow: hidden; }
  .wallet-chip .wc-flag { font-size: 8px; padding: 1px 4px; letter-spacing: 0; }
  /* The wordmark now SHARES its row with the pill and the wallet instead of
     owning one, so it has to be told it may shrink: a grid item's default
     min-width is its content, and at 22px "MEMPOOL INVADERS" is 296px of
     content in a 138px cell — it does not wrap, it overflows, and it printed
     itself straight through the OFF-CHAIN pill. Measured at 390x844. The type
     comes down to fit the row it is now in; overflow: hidden is the belt to
     that pair of braces, so the worst case is a clipped letter and never a
     collision. */
  .wordmark {
    grid-area: word; width: auto; order: 0;
    min-width: 0; overflow: hidden;
  }
  .wordmark .wm-1 { font-size: clamp(10px, 2.6vw, 12px); letter-spacing: 0.28em; }
  .wordmark .wm-2 { font-size: clamp(16px, 4.6vw, 20px); }
  /* The pill is the one object in this bar that may never be clipped — it is
     what carries the off-chain state now — so it keeps its intrinsic width and
     everything around it yields. */
  #chain-pill  { grid-area: pill; justify-self: end; min-width: max-content; }
  .wallet-chip { grid-area: chip; justify-self: end; min-width: 0; max-width: 100%; }
  .langbar     { grid-area: lang; justify-self: end; min-width: 0; }
}

/* The last shape that still exists: a 320px phone. Below about 340 the shelf
   mark, the pill and the language rail no longer fit one row together —
   measured: the mark printed through the pill and the wordmark clipped. The two
   things that survive being shortened are the mark's point size and the word
   MEMPOOL, which the tab title, the cover and the link preview all still carry.
   INVADERS, the pill and the wallet do not shrink. */
@media (max-width: 340px) {
  .brand-home { font-size: 11px; }
  .wordmark .wm-1 { display: none; }
}

@media (max-width: 480px) {
  .num-lg { font-size: 24px; }
  .gutter { padding: 6px 10px; }
  /* THE HUD STRIP MUST STAY ONE ROW. At 360px it wrapped to two — 91px instead
     of 51 — and those 40px came straight out of the bottom of the screen, which
     is where the only on-screen PAUSE lives. The shot ring is the widest thing
     in the strip and the one that can afford to give: 15 ticks still, just
     narrower. Nothing here touches the playfield's budget. */
  .gutter-l .ring { width: 84px; }
  .startbar .cta { flex: 1 1 100%; }
  .footlinks { gap: 10px; }
  /* NO --deck-h override here. It used to drop to min(52vh,420px), which on a
     dpr-3 phone caps the quantiser at 4 device pixels per logical pixel and
     costs the picture a fifth of its width for nothing — the width is what
     binds on a phone, not the height. */
}

/* Coarse pointers get the pad even on a wide tablet. */
@media (pointer: coarse) {
  .touchpad { display: flex; }
}

/* ══ TOUCH TARGETS ════════════════════════════════════════════════════════
   44px, the floor every platform agrees on. Sixteen of the thirty-eight visible
   controls were under it — every mode button, every transport button, both
   language buttons, every footer link — which is why the picker and the
   transport row felt like they were ignoring taps rather than missing them.

   Two techniques, and the choice between them is not cosmetic:

   · Where the height is free (below the playfield, and in the menu) the BOX
     grows. Honest, obvious, and the visual affordance grows with it.

   · In the TOP BAR the box may not grow. Seventeen pixels there come straight
     off the bottom of the page, and the bottom of the page is exactly where
     PAUSE is fighting for room above the home indicator. So the brand link and
     the two language buttons keep their paint and get a taller HIT AREA from a
     pseudo-element, which costs zero layout and hit-tests as the button itself
     (a pseudo-element's hits belong to its originating element). The tap target
     is 44px; the ink is not. That is the trade, made deliberately.
   ═════════════════════════════════════════════════════════════════════════ */
@media (pointer: coarse) {
  .ctl, .mode, .mode-btn, .cta, .hangar-card { min-height: 44px; }
  .stage-start { min-height: 46px; }
  #tape-verify { min-height: 44px; display: flex; align-items: center; justify-content: center; }
  .footlinks a { display: inline-flex; align-items: center; min-height: 44px; }
  .fold-sum { min-height: 44px; align-items: center; }
  #tape-root { min-height: 34px; display: flex; align-items: center; }

  /* Paint stays, hit area grows. The wallet chip joins the brand mark and the
     language buttons in the top bar: seventeen pixels of extra box up here come
     straight off the bottom of the page, which is where PAUSE is fighting the
     home indicator for room. */
  .brand-home, .lang-btn, .wallet-chip { position: relative; }
  .brand-home::after, .lang-btn::after, .wallet-chip::after {
    content: ''; position: absolute; left: 0; right: 0;
    top: 50%; transform: translateY(-50%); height: 44px;
  }
  /* 44px of WIDTH for the language buttons is affordable — it is horizontal
     room, and the top bar has it. Only the height had to be faked. */
  .lang-btn { min-width: 44px; }
  /* …and the rail's `overflow: hidden` would have CLIPPED that hit area — a
     clipped pseudo-element does not hit-test either, so the whole trick would
     have been a no-op. The rounded corners move onto the buttons instead. */
  .langbar { overflow: visible; }
  .lang-btn:first-child { border-radius: 4px 0 0 4px; }
  .lang-btn:last-child  { border-radius: 0 4px 4px 0; }

  /* THE HOME-INDICATOR STRIP, paid by the control row itself. The shell's own
     bottom padding sits below the MENU — three thousand pixels further down —
     so it does nothing whatsoever for the one row that ends the first
     screenful. This is the rule that keeps PAUSE out of the swipe-up gesture
     area on a notched phone. */
  .ctlbar { padding-bottom: calc(6px + env(safe-area-inset-bottom)); }
}

/* ══ DEVICE-TRUE INSTRUCTIONS ═════════════════════════════════════════════
   The start curtain is the first — and on a phone the ONLY — instruction a
   player ever gets, and it named two keys a phone does not have while never
   once mentioning the FIRE button sitting under the screen. Both wordings ship
   in both languages; the pointer type decides which one is real. `revert`
   rather than a fixed value, so the same pair of classes works on a <span>
   inside a sentence and on a whole <p>. */
.k-touch { display: none; }
@media (pointer: coarse) {
  .k-keys  { display: none; }
  .k-touch { display: revert; }
}

/* ══ THE SHARED RADIO, PLACED ═════════════════════════════════════════════
   /radio.js mounts one fixed 48px equaliser button bottom-left (its own CSS:
   `#txb-radio { bottom: 116px; left: 20px; z-index: 150 }`, dropping to
   `bottom: 100px; left: 14px` and a 42px button under 768px). Nothing here
   changes the widget; this only decides where its corner lands on THIS page.

   THE DESKTOP FAB KEEPS ITS DEFAULT GEOMETRY — but not its default company.
   `bottom: 116px` is right and must not be lowered (measured: at `bottom: 20px`
   the OPEN panel swallows CRT and SHAKE), and on a wide screen the shell is
   centred inside its 1280px cap so the fab lands on empty page. Below about
   1420px it does not: the shell goes flush to the left edge, the control row
   starts at x=14, and the fab covers CRT outright — measured at 1280x900,
   `document.elementFromPoint()` over the centre of #btn-crt returned
   `.radio-fab`. Same slot device as the phone, same reason.
*/
@media (min-width: 761px) and (max-width: 1420px) {
  body:has(#txb-radio) .ctlbar { padding-left: 76px; }
}

/* PAINT NARROWER, TAP THE SAME. The 44px of WIDTH the language buttons take on a
   coarse pointer was affordable when the top bar held three objects; with the
   wallet chip it is 22px this row does not have. The ink shrinks to 34px and the
   hit area is pushed back out past it with the same pseudo-element that already
   fakes the height — a pseudo-element's hits belong to its originating element,
   so the target is still 44 x 44. Declared after the touch-targets block above,
   which is the one it is amending. */
@media (max-width: 620px) and (pointer: coarse) {
  .lang-btn { min-width: 34px; }
  .lang-btn::after { left: -5px; right: -5px; }
}

/*

   THE PHONE NEEDS ONE. At the module's own mobile default the fab lands at
   14,702 and the touch pad's LEFT key is at 10,679: the radio would eat a
   steering tap, which is the exact failure that TXBW_NO_FAB exists to prevent
   for the wallet. Breaker has the same collision with its paddle and solves it
   the same way — `#txb-radio { bottom/left }` in its own head, overriding the
   shared widget from the host page rather than forking it.

   The rule is written against THIS PAGE'S breakpoint, not the widget's: invaders
   switches to phone layout at 760px and radio.js at 768px, and between those two
   widths the two would otherwise disagree about where the corner is. */
@media (max-width: 760px) {
  #txb-radio {
    left: 8px !important;
    /* Into the slot the control row leaves for it (see `body:has(#txb-radio)
       .ctlbar` above). The horizontal separation is what makes the collision
       impossible; this only decides how far down the column it sits. */
    bottom: calc(6px + env(safe-area-inset-bottom)) !important;
  }
  /* THE OPEN PANEL IS THE SECOND COLLISION, and the one that actually loses a
     run: 264x398 opening upward from a bottom-left anchor lands square on the
     touch pad, so LEFT and FIRE stop responding while a track list is up.
     Measured on this page before this rule: FIRE hit-tested to `.radio-track`.

     Lifted clear of the pad and capped, so the list scrolls inside itself. The
     !important is not decoration: radio.js injects its own <style> into <head>
     at parse time, i.e. AFTER this file's <link>, so an identically specific
     selector there wins on order alone. */
  #txb-radio .radio-panel {
    /* 210px cleared the pad by exactly 2px when measured on a 390x844 phone
       (panel bottom 628, FIRE top 630). It WORKED, but two pixels is not a
       margin — a longer playlist, a wider glyph or a different system font
       reintroduces the collision silently, and the failure mode is "FIRE stops
       responding mid-run", which reads as the game being broken. Anchor it to
       the pad's own height plus a real gap instead of a magic number. */
    bottom: calc(232px + env(safe-area-inset-bottom)) !important;
    max-height: 34vh !important;
    overflow-y: auto;
  }
  /* The toast is centred on the same band. It is not tappable, so this is only
     about the radio painting over its left edge. */
  #toast { bottom: calc(58px + env(safe-area-inset-bottom)); }
}

/* ══ THE LANDSCAPE COCKPIT ═══════════════════════════════════════════════
   DECLARED LAST ON PURPOSE. Everything below rewrites rules that the two
   @media (pointer: coarse) blocks above also set — .touchpad{display:flex} and
   the 44px .ctl floor — and those blocks are later in the file than the
   landscape query they would otherwise share a specificity tie with. Measured
   the wrong way round first: the pad stayed a flex row and all three keys piled
   into the fire column.
   ═══════════════════════════════════════════════════════════════════════════ */
@media (max-height: 560px) and (orientation: landscape) and (pointer: coarse) {
  /* ══ THE LANDSCAPE COCKPIT ══════════════════════════════════════════════
     THE BUG: on an 844x390 phone the page laid itself out as one tall column —
     top bar, then the playfield, then the HUD strip, then the tape — and the
     touch pad came out at y=493 with the control row at y=547, both of them
     below a 390px fold. To move the cannon or to press PAUSE you had to scroll
     the game off the screen first. Measured, before this block existed.

     THE FIX IS TO USE THE WIDTH. A landscape phone is 844 CSS px wide and the
     playfield is 261 of them: there are ~290px of dead page on each side of the
     picture, which is exactly where a landscape player's thumbs already are.
     So the shell becomes a cockpit — direction keys down the left flank, FIRE
     down the right, the HUD above the keys, the transport row under them, the
     tape ticker across the bottom — and the whole thing fits in one screenful
     with the picture at the size it already was.

     HOW, WITHOUT MOVING A SINGLE NODE: .shell becomes a grid and the two
     wrappers that would otherwise trap their children — <main class="deck"> and
     .touchpad — become `display: contents`, so .gutter-l, .stage-wrap,
     #tape-panel and the three .tbtn buttons place themselves directly into the
     cockpit's named areas. No DOM is reordered, no id is added, the touch bridge
     still finds `.tbtn[data-key]` exactly where it left them, and the whole
     arrangement evaporates the moment the phone is turned upright.

     THE PICTURE PAYS NOTHING: --deck-h stays min(78vh, 520px), which on a
     390px-tall screen still quantises to 3.5 device pixels per authored pixel —
     the same 261x299 frame this layout replaced. */
  .shell {
    display: grid;
    /* two narrow key columns, the picture, then the fire column */
    grid-template-columns: 66px 66px minmax(0, 1fr) 140px;
    /* The ARROW row takes the slack, so the flanks always add up to exactly the
       height of the picture beside them however tall it quantises. */
    grid-template-rows: auto auto minmax(46px, 1fr) auto auto auto;
    grid-template-areas:
      "bar bar bar bar"
      "hud hud stg fir"
      "pl  pr  stg fir"
      "ctl ctl stg fir"
      "tap tap tap tap"
      "mnu mnu mnu mnu";
    gap: 4px 6px;
    align-content: start;
    padding-bottom: calc(8px + env(safe-area-inset-bottom));
  }
  /* Eight pixels off the top bar is eight pixels the tape ticker does not spend
     inside the landscape home-indicator strip: measured, the cockpit's lowest
     element ended at 375 on a 390px screen and the strip starts at 369. */
  .topbar {
    grid-area: bar;
    padding-top: calc(3px + env(safe-area-inset-top)); padding-bottom: 3px;
  }
  .brand-home { font-size: 13px; }
  .deck      { display: contents; }
  .gutter-l  { grid-area: hud; }
  .stage-wrap { grid-area: stg; margin-left: 0; margin-right: 0; align-items: center; }
  #tape-panel { grid-area: tap; }
  .ctlbar    { grid-area: ctl; }
  .menu      { grid-area: mnu; }

  .touchpad  { display: contents; }
  .tbtn[data-code="ArrowLeft"]  { grid-area: pl; }
  .tbtn[data-code="ArrowRight"] { grid-area: pr; }
  .tbtn-fire { grid-area: fir; font-size: 16px; }
  /* A flank key is as tall as the space beside the picture — the whole edge of
     the screen is the button, which is the one control scheme a landscape phone
     has never needed to be taught. */
  .tbtn { min-height: 46px; height: 100%; }

  /* Four transport buttons in a 138px flank: two by two, still 40px of target
     each, and PAUSE is on screen at all times without a scroll. */
  .ctlbar {
    display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: 4px; padding: 0;
  }
  /* …AND THE RADIO'S SLOT IS GIVEN BACK. The portrait rule that reserves 52px at
     the left of this row carries an id inside :has(), so it outranks a plain
     `.ctlbar { padding: 0 }` no matter which is declared later — measured: the
     four labels lost a third of their width and read "RT OF / AKE 10 / OUND ( /
     PAUSE". There is nothing to reserve here anyway; the radio is stood down two
     rules below. */
  body:has(#txb-radio) .ctlbar { padding-left: 0; }
  .ctl { min-height: 40px; font-size: 9px; padding: 3px 2px; letter-spacing: 0; }

  /* THE TICKER STAYS ONE ROW. In portrait the off-chain line takes a second row
     of the tape bar because a 390px-wide ticker has no room beside it; here
     there are 844px and the row has space to spare, so the same words ride
     inline and the bottom of the cockpit stays inside the fold. */
  #tape-panel:has(#offchain-banner:not([hidden])) { flex-wrap: nowrap; }
  #offchain-banner {
    flex: 0 0 auto; order: 9;
    margin: 0; padding: 0 0 0 9px;
    border: 0; border-left: 1px solid var(--panel-line);
  }

  /* The HUD strip is a two-column block in a narrow flank now, not a wide row. */
  .gutter-l { padding: 6px 7px; gap: 3px 8px; }
  .gutter-l .ring { width: 100%; }
  .gutter-l .num-lg { font-size: 18px; }
  .gutter-l .hstat-row { gap: 8px; }

  /* THE RADIO STANDS DOWN IN LANDSCAPE, and only here. Its fixed corner is
     bottom-left, which in this layout is the transport row — the one place a
     mis-tap costs a run. Every other shape on this page keeps it. */
  #txb-radio { display: none !important; }}

/* ══ ACCESSIBILITY ════════════════════════════════════════════════════════ */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
  }
}
@media (prefers-contrast: more) {
  :root { --dim: #94A3B8; --panel-line: #3a465c; }
}
